From a3c791a0fbceff772ece14d8b6e44bf1c370fdaf Mon Sep 17 00:00:00 2001 From: Jonathan Prouse Date: Wed, 2 Sep 2026 16:35:56 +0100 Subject: [PATCH 1/2] feat: add workflow to bump version once a month --- .github/dependabot.yml | 7 + .github/workflows/monthly-rebuild.md | 106 ++++++++++++ .github/workflows/monthly-rebuild.yml | 239 ++++++++++++++++++++++++++ README.md | 7 + 4 files changed, 359 insertions(+) create mode 100644 .github/workflows/monthly-rebuild.md create mode 100644 .github/workflows/monthly-rebuild.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7ae8099..cf876f7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,12 @@ version: 2 updates: + # Covers the reusable workflows in /.github/workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + cooldown: + default-days: 7 - package-ecosystem: "github-actions" directory: "/actions/build-push-ecr" schedule: diff --git a/.github/workflows/monthly-rebuild.md b/.github/workflows/monthly-rebuild.md new file mode 100644 index 0000000..87eae0d --- /dev/null +++ b/.github/workflows/monthly-rebuild.md @@ -0,0 +1,106 @@ +# Rebuild release + +This reusable workflow cuts a PATCH-bumped release when the source did not +change. Use it in a repo that builds a container image and pins no package +versions. A rebuild with no source change picks up base-image and OS security +patches. + +The workflow creates a release only. It builds nothing. The release starts the +`release: [published]` trigger in the build workflow of the calling repo. + +# Usage + +Add a caller workflow to the repo that holds the image. The caller owns the +cron schedule and the manual dispatch inputs. + +Pin the `uses` reference to a tag or a commit. See the versioning note in the +[repo README](../../README.md). + +```yaml +name: Monthly Rebuild Release + +on: + schedule: + # 10:00 UTC on each of the first 7 days of the month. The recency guard + # keeps the release to one per window. Do not filter weekdays here: GitHub + # ORs day-of-month with day-of-week when both are restricted, so + # `1-7 * 1-5` fires every weekday of the month. The workflow checks the + # weekday itself. + - cron: "0 10 1-7 * *" + workflow_dispatch: + inputs: + force: + description: "Ignore the recency guard" + type: boolean + default: false + dry_run: + description: "Report the decision without creating a release" + type: boolean + default: false + +permissions: + contents: read + +jobs: + rebuild: + uses: GameAnalytics/github/.github/workflows/monthly-rebuild.yml@v0 + with: + # `inputs` is empty on a scheduled run, so give each one a default. + force: ${{ inputs.force || false }} + dry-run: ${{ inputs.dry_run || false }} + secrets: inherit +``` + +# Inputs + +| Name | Default | Description | +| --- | --- | --- | +| `min-days-since-release` | `14` | Minimum days since the most recent release. The workflow skips if a release is newer than this. Pre-releases count. | +| `skip-weekends` | `true` | Skip a scheduled run on Saturday and Sunday, UTC. | +| `work-hours-start` | `10` | First UTC hour of the work-hours window. Inclusive. | +| `work-hours-end` | `16` | Last UTC hour of the work-hours window. Exclusive. | +| `release-branch` | default branch | Branch to release from. | +| `tag-prefix` | `v` | Literal prefix before `MAJOR.MINOR.PATCH` in a tag name. Use `""` for bare `1.2.3` tags. | +| `release-notes` | generated | Body of the release. | +| `force` | `false` | Ignore the recency guard. | +| `dry-run` | `false` | Report the decision without creating a release. | +| `runs-on` | `ubuntu-latest` | Runner label for the job. | + +To release at any hour, set `work-hours-start: 0`, `work-hours-end: 24` and +`skip-weekends: false`. + +# Outputs + +| Name | Description | +| --- | --- | +| `released` | `true` if the workflow created a release. | +| `version` | Tag of the new release. Empty if no release was computed. | +| `previous-version` | Tag the new release was bumped from. | + +# Secrets + +`DEPLOY_GITHUB_TOKEN` is required. Use `secrets: inherit` in the caller. + +The token must be a PAT. `GITHUB_TOKEN` does not work: events raised by the +default token do not start other workflow runs, so the build workflow of the +calling repo would never run. + +# Guards + +The workflow skips, and does not fail, in these cases: + +* The run is scheduled, `skip-weekends` is set and the day is a weekend. +* The run is scheduled and the UTC hour is outside the work-hours window. + GitHub delays scheduled runs under load, so the workflow checks the hour + again at run time. +* A release is newer than `min-days-since-release` days. Set `force` to ignore + this guard. + +The workflow fails if it finds no `MAJOR.MINOR.PATCH` tag on the tip of the +release branch, or if the next tag already exists outside that branch. + +# Requirements for the calling repo + +* The release branch must carry a tag in the form `MAJOR.MINOR.PATCH`. +* The build workflow must have a `release: [published]` trigger. +* `DEPLOY_GITHUB_TOKEN` must be available to the repo. diff --git a/.github/workflows/monthly-rebuild.yml b/.github/workflows/monthly-rebuild.yml new file mode 100644 index 0000000..6345fd7 --- /dev/null +++ b/.github/workflows/monthly-rebuild.yml @@ -0,0 +1,239 @@ +name: Rebuild Release + +# Cuts a PATCH-bumped release when the source did not change. Use this workflow +# in a repo that builds a container image and pins no package versions. A +# rebuild with no source change picks up base-image and OS security patches. +# +# This workflow creates a release only. It builds nothing. The release starts +# the `release: [published]` trigger in the build workflow of the calling repo. +# +# Guards keep the release rate low and keep the release inside work hours: +# * The calling repo sets the cron window. +# * A work-hours check runs again at run time. GitHub delays scheduled runs +# under load, so the cron expression alone is not enough. +# * A recency check stops the release if a recent release already exists. +# +# See ./monthly-rebuild.md for usage. + +on: + workflow_call: + inputs: + min-days-since-release: + description: >- + Minimum days since the most recent release. The workflow skips if a + release is newer than this. Pre-releases count. + required: false + type: number + default: 14 + skip-weekends: + description: "Skip a scheduled run on Saturday and Sunday, UTC." + required: false + type: boolean + default: true + work-hours-start: + description: "First UTC hour of the work-hours window. Inclusive." + required: false + type: number + default: 10 + work-hours-end: + description: "Last UTC hour of the work-hours window. Exclusive." + required: false + type: number + default: 16 + release-branch: + description: "Branch to release from. Defaults to the default branch." + required: false + type: string + default: ${{ github.event.repository.default_branch }} + tag-prefix: + description: >- + Literal prefix before MAJOR.MINOR.PATCH in a tag name. Use an empty + string for bare `1.2.3` tags. Regular expression characters are not + supported. + required: false + type: string + default: v + release-notes: + description: >- + Body of the release. If empty, the workflow writes a default note that + names the previous version. + required: false + type: string + default: "" + force: + description: "Ignore the recency guard." + required: false + type: boolean + default: false + dry-run: + description: "Report the decision without creating a release." + required: false + type: boolean + default: false + runs-on: + description: "Runner label for the job." + required: false + type: string + default: ubuntu-latest + secrets: + DEPLOY_GITHUB_TOKEN: + description: >- + PAT used to create the release. GITHUB_TOKEN does not work here: + events raised by the default token do not start other workflow runs, + so the build workflow would never run. + required: true + outputs: + released: + description: "`true` if the workflow created a release." + value: ${{ jobs.rebuild.outputs.released }} + version: + description: "Tag of the new release. Empty if no release was computed." + value: ${{ jobs.rebuild.outputs.version }} + previous-version: + description: "Tag the new release was bumped from." + value: ${{ jobs.rebuild.outputs.previous-version }} + +jobs: + rebuild: + runs-on: ${{ inputs.runs-on }} + + permissions: + contents: read + + outputs: + released: ${{ steps.result.outputs.released }} + version: ${{ steps.result.outputs.version }} + previous-version: ${{ steps.result.outputs.previous-version }} + + steps: + - uses: actions/checkout@v4 + with: + # Needed to read the full tag list when computing the next version. + fetch-depth: 0 + + # Scheduled runs must stay inside work hours. A release can raise a + # follow-up PR, and such a PR must not block dependency updates out of + # hours. Manual dispatch is exempt. To release at any hour, set the window + # to 0 and 24 and set `skip-weekends` to false. + - name: Check work-hours window + if: github.event_name == 'schedule' + env: + SKIP_WEEKENDS: ${{ inputs.skip-weekends }} + WORK_START: ${{ inputs.work-hours-start }} + WORK_END: ${{ inputs.work-hours-end }} + run: | + set -euo pipefail + dow=$(date -u +%u) + hour=$(date -u +%-H) + if [ "$SKIP_WEEKENDS" = "true" ] && [ "$dow" -gt 5 ]; then + echo "::notice::Weekend (day $dow). Skipping." + echo "SKIP=1" >> "$GITHUB_ENV" + exit 0 + fi + if [ "$hour" -lt "$WORK_START" ] || [ "$hour" -ge "$WORK_END" ]; then + echo "::notice::${hour}:00 UTC is outside the ${WORK_START}:00-${WORK_END}:00 window. Skipping." + echo "SKIP=1" >> "$GITHUB_ENV" + exit 0 + fi + echo "Inside window: day $dow, ${hour}:00 UTC." + + # A recent release means a freshly patched image already exists, so another + # rebuild gains nothing. Pre-releases count, because that is how release + # candidates appear. This step reads releases and not tags on purpose: a + # lightweight tag reports the date of the commit it points at, not the date + # someone pushed it, so tag dates are useless here. + - name: Check for a recent release + if: env.SKIP != '1' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MIN_DAYS: ${{ inputs.min-days-since-release }} + FORCE: ${{ inputs.force }} + run: | + set -euo pipefail + # Compute the maximum explicitly. `gh release list` is not strictly + # date-ordered, so `--limit 1` can return an older release. + last=$(gh release list --limit 100 --json publishedAt \ + --jq 'map(.publishedAt) | max') + if [ -z "$last" ] || [ "$last" = "null" ]; then + echo "No previous release found. Proceeding." + exit 0 + fi + age=$(( ( $(date -u +%s) - $(date -u -d "$last" +%s) ) / 86400 )) + echo "Most recent release was $last ($age days ago)." + if [ "$age" -lt "$MIN_DAYS" ]; then + if [ "$FORCE" = "true" ]; then + echo "::warning::Only $age days since the last release, but force is set." + else + echo "::notice::Only $age days since the last release (need $MIN_DAYS). Skipping." + echo "SKIP=1" >> "$GITHUB_ENV" + fi + fi + + - name: Compute the next patch version + if: env.SKIP != '1' + env: + RELEASE_BRANCH: ${{ inputs.release-branch }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + run: | + set -euo pipefail + # Do not rely on what checkout left behind. Fetching the branch and the + # tags explicitly makes FETCH_HEAD and the tag list certain to be there. + git fetch --force --tags origin "$RELEASE_BRANCH" + # Match strict MAJOR.MINOR.PATCH only. This excludes `-rcN` pre-release + # tags and any malformed tag. + latest=$(git tag --list --merged FETCH_HEAD --sort=-v:refname \ + | grep -E "^${TAG_PREFIX}[0-9]+\.[0-9]+\.[0-9]+$" | head -1) + if [ -z "$latest" ]; then + echo "::error::Found no ${TAG_PREFIX}X.Y.Z tag on the tip of ${RELEASE_BRANCH}." + exit 1 + fi + IFS=. read -r major minor patch <<< "${latest#"$TAG_PREFIX"}" + next="${TAG_PREFIX}${major}.${minor}.$((patch + 1))" + if git rev-parse -q --verify "refs/tags/$next" > /dev/null; then + echo "::error::Tag $next already exists but is not merged into ${RELEASE_BRANCH}." + exit 1 + fi + echo "Latest release: $latest. Next: $next." + echo "LATEST=$latest" >> "$GITHUB_ENV" + echo "NEXT=$next" >> "$GITHUB_ENV" + + - name: Create the release + if: env.SKIP != '1' && inputs.dry-run != true + env: + GH_TOKEN: ${{ secrets.DEPLOY_GITHUB_TOKEN }} + RELEASE_BRANCH: ${{ inputs.release-branch }} + RELEASE_NOTES: ${{ inputs.release-notes }} + run: | + set -euo pipefail + # Write the notes explicitly. Generated notes are empty when no commits + # changed between the two tags. + notes="$RELEASE_NOTES" + if [ -z "$notes" ]; then + notes="Automated rebuild release. Picks up base-image and OS security patches. No source changes since ${LATEST}." + fi + gh release create "$NEXT" \ + --target "$RELEASE_BRANCH" \ + --title "$NEXT" \ + --notes "$notes" + echo "::notice::Created release $NEXT." + + - name: Report a dry run + if: env.SKIP != '1' && inputs.dry-run == true + run: echo "::notice::Dry run. Would have created release $NEXT from $LATEST." + + - name: Report the outcome + id: result + env: + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + if [ "${SKIP:-}" = "1" ] || [ "$DRY_RUN" = "true" ]; then + released=false + else + released=true + fi + { + echo "released=$released" + echo "version=${NEXT:-}" + echo "previous-version=${LATEST:-}" + } >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 050fa07..8e8141f 100644 --- a/README.md +++ b/README.md @@ -13,5 +13,12 @@ Number of helpful actions for GitHub Actions * [download-private-asset](./actions/download-private-asset/README.md) +# reusable workflows +Workflows that a caller repo runs with `uses` at job level. Intentionally sit at +a different level to composite actions. +Useful if you want to preserve access to secrets from the calling workflow. + +* [monthly-rebuild](./.github/workflows/monthly-rebuild.md) + # License This code is made available under the MIT license. From b668beccc5888aa6daccc00d6052bde8440c2c62 Mon Sep 17 00:00:00 2001 From: Jonathan Prouse Date: Wed, 2 Sep 2026 16:52:35 +0100 Subject: [PATCH 2/2] ci: add simple lint check --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ .yamllint.yml | 7 +++++++ 2 files changed, 36 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .yamllint.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..820b481 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +# Checks every YAML file in the repo. The repo holds actions and reusable +# workflows, so a YAML file that does not parse breaks each caller repo that +# uses it. + +on: + pull_request: + push: + branches: [v0] + workflow_dispatch: + +permissions: + contents: read + +jobs: + yaml: + name: YAML syntax + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # yamllint comes preinstalled on the GitHub ubuntu runners, so this step + # needs no install. `-f github` writes annotations onto the diff. + # `--strict` makes a warning fail the job, so a rule that fires is never + # silent. The rules are in .yamllint.yml. + - name: Check YAML + run: yamllint --strict -f github . diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..6e05a0f --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,7 @@ +# Style rules stay loose: this repo holds YAML that other repos consume, so the +# check must catch broken syntax and not argue about layout. +extends: relaxed + +rules: + # Shell scripts inside `run:` blocks do not fit in 80 columns. + line-length: disable