From e6db6d5050b648f3ba3b60de3f8a5a4a18b26e7d Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 29 Oct 2026 12:17:12 +0100 Subject: [PATCH] ci: release as commit, tag, draft, then a ci-build dispatch The tag no longer triggers ci-build, so ci-build never starts before the draft exists. release.yml now publishes the release commit on its own, so a rejected commit never strands an immutable tag, then the tag, then stages the draft against the tag, and last dispatches ci-build on the tag with release=true. ci-build's gate sends a release dispatch to prod and refuses one that is not on a tag, and only that dispatch runs the release job; a manual dispatch on a tag still republishes without publishing the release. A version input lets a master release use a given version instead of the computed one, e.g. to skip one an immutable release used up; release-tool refuses it on beta. --- .github/workflows/ci-build.yml | 16 ++++++++------- .github/workflows/release.yml | 32 ++++++++++++++++++++++------- scripts/ci_build_gate.py | 14 ++++++------- scripts/repo_server_policy.py | 2 +- scripts/tests/test_ci_build_gate.py | 22 ++++++++++++-------- 5 files changed, 54 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index a6113875..9c17e866 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -1,5 +1,5 @@ # Compiles once per build variant (scripts/utils/boards.py) and stamps each board from it. Runs on: -# - release tags: every board, published to prod; +# - release tags, dispatched by release.yml once the draft is staged: every board, published to prod; # - nightly on develop when firmware sources changed, published to prod; # - pull requests: one canary board, or every board with the ci:full-build label; # - dispatch: every board, on demand. @@ -7,10 +7,6 @@ name: ci-build on: - push: - tags: - - '[0-9]+.[0-9]+.[0-9]+' - - '[0-9]+.[0-9]+.[0-9]+-*' pull_request: branches: - master @@ -32,6 +28,11 @@ on: type: choice default: derive options: [derive, develop, beta, stable] + release: + description: 'Set by release.yml: publish this release tag to prod and publish its staged draft release' + required: false + type: boolean + default: false schedule: - cron: '0 3 * * *' # Runs on the default branch, develop @@ -331,9 +332,10 @@ jobs: runs-on: ubuntu-latest needs: [gate, plan, repo-server] timeout-minutes: 5 - # Pushes only trigger on release tags; a dispatch against a tag publishes without cutting a release. + # Only release.yml's dispatch publishes the release; a manual dispatch against a tag republishes without it. if: >- - github.event_name == 'push' + github.event_name == 'workflow_dispatch' + && inputs.release && startsWith(github.ref, 'refs/tags/') permissions: contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da935641..e6f21634 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,11 @@ name: release on: # Releases are cut manually, never on push. Run from beta for an rc, from master for a stable release. workflow_dispatch: + inputs: + version: + description: 'master only: release this MAJOR.MINOR.PATCH instead of the computed version (e.g. to skip a version an immutable release used up)' + required: false + default: '' concurrency: group: release-${{ github.ref }} @@ -10,6 +15,7 @@ concurrency: permissions: contents: write + actions: write # dispatch ci-build on the release tag defaults: run: @@ -23,7 +29,7 @@ jobs: if: github.ref_name == 'beta' || github.ref_name == 'master' steps: - # An App token, not GITHUB_TOKEN, so the tag push below triggers ci-build + # An App token pushes the release commit and tag - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 id: app-token with: @@ -50,6 +56,7 @@ jobs: mode: release output: release.json notes-output: release-notes.md + version: ${{ inputs.version }} env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} @@ -60,20 +67,31 @@ jobs: echo "::error::No change files present since the last tag - nothing to release." exit 1 - - name: Push commit and tag - uses: OpenShock/actions/publish-tag@21ca2d511bca92cf24fb502fa302f82700822584 # v1.1.1 - with: - tag: ${{ steps.meta.outputs.tag }} + # The commit first: tags are immutable, so a tag pushed with a rejected commit would be stranded on no branch + - name: Push release commit + run: git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" + + - name: Push tag + env: + TAG: ${{ steps.meta.outputs.tag }} + run: git push origin "refs/tags/${TAG}" - # A draft, the tag push triggers ci-build whose release job attaches the binaries and publishes it + # A draft until ci-build's release job attaches the binaries and publishes it - name: Stage GitHub Release (draft) env: GH_TOKEN: ${{ steps.app-token.outputs.token }} TAG: ${{ steps.meta.outputs.tag }} PRERELEASE: ${{ steps.meta.outputs.prerelease }} run: | - ARGS=("$TAG" "release.json" --title "$TAG" --notes-file release-notes.md --draft) + ARGS=("$TAG" "release.json" --title "$TAG" --notes-file release-notes.md --draft --verify-tag) if [ "$PRERELEASE" = "true" ]; then ARGS+=(--prerelease) fi gh release create "${ARGS[@]}" + + # The tag push does not trigger ci-build; this dispatch does, once the commit, tag and draft all exist + - name: Dispatch ci-build for the tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.meta.outputs.tag }} + run: gh workflow run ci-build.yml --ref "$TAG" -f release=true diff --git a/scripts/ci_build_gate.py b/scripts/ci_build_gate.py index 3ae9cfd3..584148cd 100644 --- a/scripts/ci_build_gate.py +++ b/scripts/ci_build_gate.py @@ -146,6 +146,12 @@ def _decide_nightly(ref: str, sha: str, api: Api) -> Decision: def _decide_dispatch(ref: str, inputs: dict) -> Decision: + # release.yml dispatches a release tag once its draft is staged; that always goes to prod. + if str(inputs.get('release')).lower() == 'true': + if parse_ref(ref).kind != 'tag': + fail(f'A release was dispatched on {ref}; releases run against their tag.') + return Decision(build=True, publish=True, server='prod') + # A manual dispatch always publishes; the channel and the ref are chosen by hand. d = Decision(build=True, publish=True, server='prod' if inputs.get('server') == 'prod' else 'dev') # The ref picker offers tags as well as branches, so prod still means running against the tag being released. @@ -155,12 +161,6 @@ def _decide_dispatch(ref: str, inputs: dict) -> Decision: return d -def _decide_push(ref: str) -> Decision: - # The workflow only triggers on release tags, which publish to prod; a branch push builds without publishing. - is_tag = parse_ref(ref).kind == 'tag' - return Decision(build=True, publish=is_tag, server='prod' if is_tag else 'dev') - - def decide(event_name: str, ref: str, sha: str, payload: dict, api: Api) -> Decision: if event_name == 'pull_request': return _decide_pull_request(payload) @@ -168,8 +168,6 @@ def decide(event_name: str, ref: str, sha: str, payload: dict, api: Api) -> Deci return _decide_nightly(ref, sha, api) if event_name == 'workflow_dispatch': return _decide_dispatch(ref, payload.get('inputs') or {}) - if event_name == 'push': - return _decide_push(ref) fail(f'Unsupported event {event_name!r}') diff --git a/scripts/repo_server_policy.py b/scripts/repo_server_policy.py index 1a31e9f5..d3b07993 100644 --- a/scripts/repo_server_policy.py +++ b/scripts/repo_server_policy.py @@ -46,7 +46,7 @@ def require_staged_draft(repo: str, version: str) -> None: """release.yml stages a draft for the version before it may go live; a stray tag must not reach a channel devices follow. A draft is not reachable by tag name, so this reads the release list. - release.yml pushes the tag that triggers this run and only then stages the draft, so poll through that window. + release.yml stages the draft before dispatching this run; poll anyway, as the release list can lag. """ for attempt in range(1, DRAFT_ATTEMPTS + 1): releases = github_json(f'/repos/{repo}/releases?per_page=100') or [] diff --git a/scripts/tests/test_ci_build_gate.py b/scripts/tests/test_ci_build_gate.py index 7bacb8ee..069c7953 100644 --- a/scripts/tests/test_ci_build_gate.py +++ b/scripts/tests/test_ci_build_gate.py @@ -91,22 +91,26 @@ def test_pr_labelled_with_another_label_while_carrying_full_build_still_does_not # --- release tags --- -@pytest.mark.parametrize('tag', ['1.5.0', '1.5.0-rc.1', '1.5.0-develop.3']) -def test_tag_push_builds_and_publishes_to_prod(tag): +@pytest.mark.parametrize('tag', ['1.5.0', '1.5.0-rc.1']) +@pytest.mark.parametrize('release_input', ['true', True]) +def test_release_dispatch_builds_and_publishes_to_prod(tag, release_input): api = FakeApi() - d = decide('push', f'refs/tags/{tag}', SHA, {}, api) + # release.yml leaves server at its dev default; a release goes to prod regardless. + d = decide('workflow_dispatch', f'refs/tags/{tag}', SHA, {'inputs': {'release': release_input}}, api) assert outcome(d) == (True, True, 'prod', '') assert api.run_queries == [] and api.compares == [] -def test_branch_push_builds_without_publishing(): - d = decide('push', 'refs/heads/develop', SHA, {}, FakeApi()) - assert outcome(d) == (True, False, 'dev', '') +def test_release_dispatch_on_a_branch_is_refused(capsys): + with fails_with(capsys, 'A release was dispatched on refs/heads/master'): + decide('workflow_dispatch', 'refs/heads/master', SHA, {'inputs': {'release': 'true'}}, FakeApi()) -def test_an_unsupported_event_is_refused(capsys): - with fails_with(capsys, "Unsupported event 'release'"): - decide('release', 'refs/tags/1.5.0', SHA, {}, FakeApi()) +@pytest.mark.parametrize('event', ['push', 'release']) +def test_an_unsupported_event_is_refused(event, capsys): + # Tag pushes no longer trigger ci-build: release.yml dispatches it once the draft is staged. + with fails_with(capsys, f'Unsupported event {event!r}'): + decide(event, 'refs/tags/1.5.0', SHA, {}, FakeApi()) # --- nightlies ---