Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@ jobs:
timeout-minutes: 35
steps:
- name: Check out repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc

- name: Set up Java
uses: actions/setup-java@v5
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: temurin
java-version: '17'
Expand All @@ -46,7 +46,7 @@ jobs:
run: bash scripts/ci/build-channel.sh candidate debug

- name: Publish candidate debug artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: candidate-debug-${{ github.sha }}
path: artifacts/ci-candidate-debug/
Expand All @@ -72,10 +72,10 @@ jobs:
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Download candidate debug artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: candidate-debug-${{ github.sha }}
path: artifacts/ci-candidate-debug
Expand All @@ -94,14 +94,14 @@ jobs:
sudo udevadm trigger --name-match=kvm

- name: Set up Java
uses: actions/setup-java@v5
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: temurin
java-version: '17'
cache: gradle

- name: Run adb runtime smoke gate
uses: reactivecircus/android-emulator-runner@v2
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
env:
EVIDENCE_DIR: ${{ github.workspace }}/artifacts/emulator-smoke
with:
Expand All @@ -115,7 +115,7 @@ jobs:

- name: Publish smoke evidence
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: emulator-smoke-${{ github.sha }}
path: artifacts/emulator-smoke/
Expand Down
94 changes: 67 additions & 27 deletions .github/workflows/molstar-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ jobs:
timeout-minutes: 45
steps:
- name: Check out main
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: main
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc

Expand All @@ -43,7 +43,7 @@ jobs:

- name: Set up Java
if: steps.check.outputs.available == 'true'
uses: actions/setup-java@v5
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: temurin
java-version: '17'
Expand All @@ -53,41 +53,40 @@ jobs:
if: steps.check.outputs.available == 'true'
run: bash scripts/ci/ensure-android-sdk.sh

- name: Configure release signing
if: steps.check.outputs.available == 'true'
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
for name in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do
[[ -n "${!name}" ]] || { echo "Missing signing secret: $name" >&2; exit 1; }
done
keystore="$RUNNER_TEMP/sideload.jks"
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore"
chmod 600 "$keystore"
{
echo "MOLSTAR_ANDROID_KEYSTORE_FILE=$keystore"
echo "MOLSTAR_ANDROID_KEYSTORE_PASSWORD=$KEYSTORE_PASSWORD"
echo "MOLSTAR_ANDROID_KEY_ALIAS=$KEY_ALIAS"
echo "MOLSTAR_ANDROID_KEY_PASSWORD=$KEY_PASSWORD"
} >> "$GITHUB_ENV"

- name: Derive candidate package version
if: steps.check.outputs.available == 'true'
env:
TARGET_VERSION: ${{ steps.check.outputs.target }}
run: bash scripts/ci/github-version-env.sh candidate "$GITHUB_RUN_NUMBER" "$TARGET_VERSION" >> "$GITHUB_ENV"

# The signing key exists only inside this step: it is passed through this
# step's environment, never GITHUB_ENV, and the keystore file is deleted when
# the step ends. The emulator step below runs a third-party action and only
# installs the finished APK, so it has no use for the key and must not see it.
- name: Prepare signed update candidate
if: steps.check.outputs.available == 'true'
env:
TARGET_VERSION: ${{ steps.check.outputs.target }}
UPDATE_OUTPUT_DIR: ${{ runner.temp }}/molstar-update
UPDATE_BUILD_TYPE: release
BUILD_CANDIDATE: '1'
run: bash scripts/automation/prepare-molstar-update.sh "$TARGET_VERSION"
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
for name in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do
[[ -n "${!name}" ]] || { echo "Missing signing secret: $name" >&2; exit 1; }
done
keystore="$RUNNER_TEMP/sideload.jks"
trap 'rm -f "$keystore"' EXIT
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore"
chmod 600 "$keystore"
export MOLSTAR_ANDROID_KEYSTORE_FILE="$keystore"
export MOLSTAR_ANDROID_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD"
export MOLSTAR_ANDROID_KEY_ALIAS="$KEY_ALIAS"
export MOLSTAR_ANDROID_KEY_PASSWORD="$KEY_PASSWORD"
bash scripts/automation/prepare-molstar-update.sh "$TARGET_VERSION"

- name: Enable KVM
if: steps.check.outputs.available == 'true'
Expand All @@ -103,7 +102,7 @@ jobs:
# structure file on a device.
- name: Verify the candidate on an emulator
if: steps.check.outputs.available == 'true'
uses: reactivecircus/android-emulator-runner@v2
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
env:
EVIDENCE_DIR: ${{ runner.temp }}/molstar-update/emulator-smoke
with:
Expand Down Expand Up @@ -146,7 +145,7 @@ jobs:
# only actionable if its evidence survives the failure.
- name: Upload candidate and update report
if: always() && steps.check.outputs.available == 'true'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: molstar-${{ steps.check.outputs.target }}-candidate
path: ${{ runner.temp }}/molstar-update/
Expand All @@ -159,3 +158,44 @@ jobs:
echo '## Molstar is already current' >> "$GITHUB_STEP_SUMMARY"
echo >> "$GITHUB_STEP_SUMMARY"
echo "Current version: \`${{ steps.check.outputs.current }}\`" >> "$GITHUB_STEP_SUMMARY"

# Separate job so the permissions below never reach the job that holds the
# signing key. An unattended weekly run that fails would otherwise only show up
# as an email, so the failure is also recorded as one open issue that later
# failures add to instead of opening a new one each week.
notify:
needs: update
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
actions: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
steps:
- name: Report a failed update run
if: needs.update.result == 'failure'
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
label='automation-failure'
gh label create "$label" --color B60205 \
--description 'The unattended Mol* update workflow failed' 2>/dev/null || true
body="The Molstar update workflow failed: $RUN_URL"
number="$(gh issue list --label "$label" --state open --json number --jq '.[0].number // empty')"
if [[ -n "$number" ]]; then
gh issue comment "$number" --body "$body"
else
gh issue create --label "$label" \
--title 'Molstar update workflow is failing' \
--body "$body"$'\n\nThe weekly run opens no new issue while this one is open. Close it once a run succeeds.'
fi

# GitHub disables scheduled workflows in a public repository after 60 days
# without repository activity, and a blocked update means no commits. Enabling
# the workflow on every scheduled run is a best-effort keepalive.
- name: Keep the schedule enabled
if: github.event_name == 'schedule'
run: gh api --method PUT "repos/$GH_REPO/actions/workflows/molstar-update.yml/enable"
8 changes: 4 additions & 4 deletions .github/workflows/promote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
environment: stable-release
steps:
- name: Check out main
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: main
fetch-depth: 0
Expand All @@ -40,12 +40,12 @@ jobs:
git checkout -B main "$actual"

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc

- name: Set up Java
uses: actions/setup-java@v5
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: temurin
java-version: '17'
Expand Down Expand Up @@ -90,7 +90,7 @@ jobs:

- name: Preserve release evidence
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: stable-release-${{ github.sha }}
path: ${{ runner.temp }}/release/
Expand Down
14 changes: 13 additions & 1 deletion docs/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ This makes the gate load-bearing: it is the only check between an upstream relea
unattended updates unverified. `scripts/verify-automation-contract.mjs` therefore requires
the update workflow to invoke `scripts/ci/emulator-smoke.sh`.

Every action in the workflows is pinned to a commit SHA, with the release tag in a trailing
comment, and the contract script rejects a tag reference. To move to a newer release, resolve
the tag to its commit (`gh api repos/<owner>/<action>/commits/<tag> --jq .sha`) and replace
both the SHA and the comment. In the update workflow the signing key is read only by the
build step and removed when that step ends, so the third-party emulator action never sees it.

When a scheduled update run fails, the `notify` job opens one issue labelled
`automation-failure` and adds a comment to it on later failures. Close the issue once a run
succeeds. The same job re-enables the workflow on every scheduled run as a best-effort guard
against GitHub disabling schedules after 60 days without repository activity; if the weekly
runs stop appearing, check the Actions tab and enable the workflow by hand.

## Signing and stable releases

Stable Android updates require the same application ID and long-lived signing identity. Candidate builds use a separate application ID so they can be installed beside stable.
Expand All @@ -61,7 +73,7 @@ Configure repository signing secrets from a trusted workstation with:
bash scripts/release/configure-github-signing.sh
```

The helper manages the permanent keystore outside the repository and configures the encrypted GitHub secrets expected by the workflows.
The helper manages the permanent keystore outside the repository and configures the encrypted GitHub secrets expected by the workflows. GitHub secrets cannot be read back, so keep an encrypted offline copy of the keystore and its passwords. Losing either means stable updates can no longer be signed with the installed identity, and every user would have to uninstall and reinstall.

Stable promotion is manual. The workflow accepts only the full commit SHA currently at `main`, and that exact commit must first be installed and approved on a real Android device.

Expand Down
10 changes: 8 additions & 2 deletions scripts/verify-automation-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ function requireMatch(condition, message) {
}
function requireOfficialActions(workflow, name) {
for (const action of ['actions/checkout@v7', 'actions/setup-node@v7', 'actions/setup-java@v5', 'actions/upload-artifact@v7']) {
if (action.includes('setup-java') && name === 'Molstar update' && !workflow.includes(action)) continue;
requireMatch(workflow.includes(action), `${name} must use ${action}`);
const [repo, major] = action.split('@');
requireMatch(new RegExp(`uses: ${repo}@[0-9a-f]{40} # ${major}\\b`).test(workflow), `${name} must use ${action} pinned to a commit SHA`);
}
for (const line of workflow.split('\n').filter(line => /^\s*(-\s+)?uses:/.test(line))) {
requireMatch(/@[0-9a-f]{40}\s+# \S+/.test(line), `${name} must pin every action to a commit SHA: ${line.trim()}`);
}
requireMatch(!workflow.includes('pull_request_target'), `${name} must not use pull_request_target`);
}
Expand Down Expand Up @@ -53,6 +56,9 @@ requireMatch(updateWorkflow.includes('contents: write'), 'update workflow cannot
requireMatch(!updateWorkflow.includes('pull-requests:'), 'update workflow must not hold pull request permission it no longer uses');
requireMatch(updateWorkflow.includes('scripts/ci/emulator-smoke.sh'), 'update workflow must gate the candidate on the runtime smoke test before pushing to main');
requireMatch(updateWorkflow.includes('scripts/automation/prepare-molstar-update.sh'), 'update workflow must delegate to the repository update script');
requireMatch(!/echo\s+"MOLSTAR_ANDROID_KEY/.test(updateWorkflow),'update workflow must not export signing material to later steps');
requireMatch((updateWorkflow.match(/secrets\.ANDROID_KEYSTORE_BASE64/g) || []).length === 1 && updateWorkflow.indexOf('secrets.ANDROID_KEYSTORE_BASE64') < updateWorkflow.indexOf('android-emulator-runner'), 'signing secrets must be read once, by the build step before the emulator step');
requireMatch(updateWorkflow.includes('automation-failure'), 'update workflow must record failed unattended runs');
requireMatch(updateWorkflow.includes('app/src/main/assets/viewer/vendor/molstar'), 'update workflow must stage only the upstream vendor layer');
requireMatch(promoteWorkflow.includes('workflow_dispatch'), 'stable promotion must be manual');
requireMatch(promoteWorkflow.includes('approved_commit'), 'stable promotion must require the device-approved commit SHA');
Expand Down
Loading