Skip to content
Open
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
16 changes: 9 additions & 7 deletions .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
# 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.
# Per-commit checks are host-tests.yml, script-tests.yml, frontend-checks.yml and cpp-linter.yml.
name: ci-build

on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+-*'
pull_request:
branches:
- master
Expand All @@ -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

Expand Down Expand Up @@ -332,9 +333,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
Expand Down
32 changes: 25 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ 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 }}
cancel-in-progress: false

permissions:
contents: write
actions: write # dispatch ci-build on the release tag

defaults:
run:
Expand All @@ -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:
Expand All @@ -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 }}

Expand All @@ -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
14 changes: 6 additions & 8 deletions scripts/ci_build_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -155,21 +161,13 @@ 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)
if event_name == 'schedule':
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}')


Expand Down
2 changes: 1 addition & 1 deletion scripts/repo_server_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand Down
22 changes: 13 additions & 9 deletions scripts/tests/test_ci_build_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
Loading