From dd44a94eb52cb13a5bbb376220b2e5ee75f98461 Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 16:40:47 +0000 Subject: [PATCH 01/10] docs: publish production docs only from the latest stable release --- .github/workflows/docs.yaml | 134 +++++++++- .github/workflows/release-upgrade.yaml | 27 ++ hack/release/docs_workflow_test.go | 343 +++++++++++++++++++++++++ 3 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 hack/release/docs_workflow_test.go diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index e8bb8bd38a..3da07b43d9 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -3,28 +3,145 @@ name: unbounded docs +# --------------------------------------------------------------------------- +# Publishes the production docs site from the latest published stable release +# only. Pushes to main never deploy: the site must describe what users can +# install, not what has merged since. +# +# Three ways in: +# +# release: published a release published by a human. GitHub suppresses +# this event for releases published with GITHUB_TOKEN, +# so the automated publish path cannot rely on it. +# workflow_call release-upgrade.yaml after it publishes a Latest +# release. A reusable call rather than a dispatch, so +# the caller needs no actions: write and older tags +# never need to know about this workflow's inputs. +# workflow_dispatch manual retry for a given tag, from main only. +# +# Whatever the trigger, `resolve` checks the tag against the repository's +# latest published release before anything is checked out. Drafts and +# prereleases are never returned by releases/latest, so they cannot pass. +# --------------------------------------------------------------------------- + on: - push: - branches: [main] - paths: - - "docs/**" - - ".github/workflows/docs.yaml" + release: + types: [published] workflow_dispatch: + inputs: + tag: + description: "Release tag to publish docs from; must be the latest published stable release" + required: true + type: string + workflow_call: + inputs: + tag: + description: "Release tag to publish docs from; must be the latest published stable release" + required: true + type: string permissions: contents: read - pages: write - id-token: write concurrency: group: pages cancel-in-progress: false jobs: + resolve: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.validate.outputs.tag }} + sha: ${{ steps.validate.outputs.sha }} + steps: + # Inputs reach the shell only through env vars, never through expression + # interpolation into the script, so a crafted tag cannot inject commands. + # + # In a reusable workflow github.event_name and github.ref are the + # caller's, so only the release event is treated specially: every other + # event, including release-upgrade's workflow_run, must supply a tag and + # run on main. + - name: Validate tag + id: validate + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.tag }} + RUN_REF: ${{ github.ref }} + run: | + set -euo pipefail + + # Ineligible human-published releases (prereleases, old-series + # patches) are skipped quietly; an explicit request that cannot be + # honored fails, so a broken retry or automation is visible. + reject() { + if [ "$EVENT_NAME" = "release" ]; then + echo "::notice::Skipping docs: $1" + exit 0 + fi + echo "::error::$1" + exit 1 + } + + if [ "$EVENT_NAME" = "release" ]; then + TAG="$EVENT_TAG" + else + TAG="$INPUT_TAG" + if [ "$RUN_REF" != "refs/heads/main" ]; then + echo "::error::docs must be requested from refs/heads/main, not ${RUN_REF}" + exit 1 + fi + fi + + # Stable semver only: no prerelease suffix, no shell metacharacters. + if ! printf '%s' "$TAG" | grep -Eqx 'v[0-9]+\.[0-9]+\.[0-9]+'; then + reject "'${TAG}' is not a stable release tag" + fi + + # releases/latest returns only published, non-prerelease releases. + LATEST_TAG="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)" \ + || reject "could not look up the latest release" + if [ "$TAG" != "$LATEST_TAG" ]; then + reject "${TAG} is not the latest published release (${LATEST_TAG})" + fi + + # Pin the commit now so build cannot pick up a tag moved after + # validation. + SHA="$(gh api "repos/${GITHUB_REPOSITORY}/commits/refs/tags/${TAG}" --jq .sha)" \ + || reject "could not resolve ${TAG} to a commit" + if ! printf '%s' "$SHA" | grep -Eqx '[0-9a-f]{40}'; then + reject "${TAG} resolved to an invalid commit '${SHA}'" + fi + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + echo "Publishing docs from ${TAG} (${SHA})." + build: + needs: resolve + if: needs.resolve.outputs.tag != '' runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.resolve.outputs.tag }} + persist-credentials: false + + - name: Verify checkout matches the validated commit + env: + EXPECTED_SHA: ${{ needs.resolve.outputs.sha }} + run: | + set -euo pipefail + ACTUAL_SHA="$(git rev-parse HEAD)" + if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then + echo "::error::checked out ${ACTUAL_SHA}, validated ${EXPECTED_SHA}" + exit 1 + fi - uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1 with: @@ -46,6 +163,9 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/release-upgrade.yaml b/.github/workflows/release-upgrade.yaml index a00a2c92c7..ce098c006d 100644 --- a/.github/workflows/release-upgrade.yaml +++ b/.github/workflows/release-upgrade.yaml @@ -1148,6 +1148,33 @@ jobs: echo "- Artifact signatures and BOM: verified" } >> "$GITHUB_STEP_SUMMARY" + # --------------------------------------------------------------------------- + # Publish the production docs site from the release that just became Latest. + # + # Releases published with GITHUB_TOKEN do not emit the `release` event, so + # docs.yaml's own trigger never fires for this path. A reusable call keeps the + # caller free of actions: write and works no matter which tag is deployed, + # since the called workflow comes from this run's commit, not the tag. + # + # Either publish path counts; they are mutually exclusive. Non-Latest + # releases (prereleases, older-series patches) never replace the site. + # docs.yaml re-validates the tag against releases/latest regardless. + # --------------------------------------------------------------------------- + docs: + needs: [resolve, publish, publish-forced] + if: > + !cancelled() && + needs.resolve.result == 'success' && + needs.resolve.outputs.latest == 'true' && + (needs.publish.result == 'success' || needs.publish-forced.result == 'success') + permissions: + contents: read + pages: write + id-token: write + uses: ./.github/workflows/docs.yaml + with: + tag: ${{ needs.resolve.outputs.tag }} + # --------------------------------------------------------------------------- # CUSTOMIZE: Teams notification (NOT YET ENABLED) diff --git a/hack/release/docs_workflow_test.go b/hack/release/docs_workflow_test.go new file mode 100644 index 0000000000..0b96dab805 --- /dev/null +++ b/hack/release/docs_workflow_test.go @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +package release + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +type docsStep struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + With map[string]string `yaml:"with"` + Env map[string]string `yaml:"env"` +} + +type docsJob struct { + Needs any `yaml:"needs"` + If string `yaml:"if"` + Uses string `yaml:"uses"` + With map[string]string `yaml:"with"` + Permissions map[string]string `yaml:"permissions"` + Steps []docsStep `yaml:"steps"` +} + +type docsWorkflow struct { + On map[string]yaml.Node `yaml:"on"` + Permissions map[string]string `yaml:"permissions"` + Concurrency struct { + Group string `yaml:"group"` + CancelInProgress bool `yaml:"cancel-in-progress"` + } `yaml:"concurrency"` + Jobs map[string]docsJob `yaml:"jobs"` +} + +func loadWorkflow(t *testing.T, name string) docsWorkflow { + t.Helper() + + data, err := os.ReadFile(filepath.Join("../../.github/workflows", name)) + if err != nil { + t.Fatal(err) + } + + var wf docsWorkflow + if err := yaml.Unmarshal(data, &wf); err != nil { + t.Fatal(err) + } + + return wf +} + +func TestDocsWorkflowTriggers(t *testing.T) { + t.Parallel() + + wf := loadWorkflow(t, "docs.yaml") + + if _, ok := wf.On["push"]; ok { + t.Fatal("docs must not deploy on push") + } + + var release struct { + Types []string `yaml:"types"` + } + if n, ok := wf.On["release"]; !ok { + t.Fatal("docs must trigger on release") + } else if err := n.Decode(&release); err != nil { + t.Fatal(err) + } + + if strings.Join(release.Types, ",") != "published" { + t.Fatalf("release types = %v, want [published]", release.Types) + } + + for _, event := range []string{"workflow_dispatch", "workflow_call"} { + var trig struct { + Inputs map[string]struct { + Required bool `yaml:"required"` + Type string `yaml:"type"` + } `yaml:"inputs"` + } + + n, ok := wf.On[event] + if !ok { + t.Fatalf("docs must trigger on %s", event) + } + + if err := n.Decode(&trig); err != nil { + t.Fatal(err) + } + + tag := trig.Inputs["tag"] + if !tag.Required || tag.Type != "string" { + t.Fatalf("%s tag input = %+v, want required string", event, tag) + } + } + + if wf.Concurrency.Group != "pages" || wf.Concurrency.CancelInProgress { + t.Fatalf("concurrency = %+v, want pages without cancel", wf.Concurrency) + } +} + +func TestDocsWorkflowPermissionsAndOrdering(t *testing.T) { + t.Parallel() + + wf := loadWorkflow(t, "docs.yaml") + + if len(wf.Permissions) != 1 || wf.Permissions["contents"] != "read" { + t.Fatalf("workflow permissions = %v, want contents: read only", wf.Permissions) + } + + for _, name := range []string{"resolve", "build"} { + p := wf.Jobs[name].Permissions + if len(p) != 1 || p["contents"] != "read" { + t.Fatalf("%s permissions = %v, want contents: read only", name, p) + } + } + + deploy := wf.Jobs["deploy"] + if len(deploy.Permissions) != 2 || deploy.Permissions["pages"] != "write" || + deploy.Permissions["id-token"] != "write" { + t.Fatalf("deploy permissions = %v", deploy.Permissions) + } + + if deploy.Needs != "build" { + t.Fatalf("deploy needs = %v, want build", deploy.Needs) + } + + build := wf.Jobs["build"] + if build.Needs != "resolve" || build.If != "needs.resolve.outputs.tag != ''" { + t.Fatalf("build must wait for a validated tag: needs=%v if=%q", build.Needs, build.If) + } + + checkout := build.Steps[0] + if !strings.HasPrefix(checkout.Uses, "actions/checkout@") || + checkout.With["ref"] != "refs/tags/${{ needs.resolve.outputs.tag }}" { + t.Fatalf("build first step must check out the validated tag: %+v", checkout) + } + + if !strings.Contains(build.Steps[1].Run, `"$ACTUAL_SHA" != "$EXPECTED_SHA"`) || + build.Steps[1].Env["EXPECTED_SHA"] != "${{ needs.resolve.outputs.sha }}" { + t.Fatalf("build must verify the validated commit: %+v", build.Steps[1]) + } + + for _, step := range wf.Jobs["resolve"].Steps { + if strings.HasPrefix(step.Uses, "actions/checkout@") { + t.Fatal("resolve must not check anything out before validation") + } + + if strings.Contains(step.Run, "${{") { + t.Fatalf("resolve script interpolates expressions; use env vars:\n%s", step.Run) + } + } +} + +func TestReleaseUpgradeCallsDocs(t *testing.T) { + t.Parallel() + + wf := loadWorkflow(t, "release-upgrade.yaml") + docs, ok := wf.Jobs["docs"] + if !ok { + t.Fatal("release-upgrade must call docs") + } + + if docs.Uses != "./.github/workflows/docs.yaml" || + docs.With["tag"] != "${{ needs.resolve.outputs.tag }}" { + t.Fatalf("docs call = uses %q with %v", docs.Uses, docs.With) + } + + needs, _ := docs.Needs.([]any) + got := []string{} + for _, n := range needs { + got = append(got, n.(string)) + } + + if strings.Join(got, ",") != "resolve,publish,publish-forced" { + t.Fatalf("docs needs = %v", got) + } + + for _, want := range []string{ + "!cancelled()", + "needs.resolve.result == 'success'", + "needs.resolve.outputs.latest == 'true'", + "(needs.publish.result == 'success' || needs.publish-forced.result == 'success')", + } { + if !strings.Contains(docs.If, want) { + t.Fatalf("docs condition missing %q:\n%s", want, docs.If) + } + } + + if len(docs.Permissions) != 3 || docs.Permissions["contents"] != "read" || + docs.Permissions["pages"] != "write" || docs.Permissions["id-token"] != "write" { + t.Fatalf("docs permissions = %v", docs.Permissions) + } + + if _, ok := docs.Permissions["actions"]; ok { + t.Fatal("docs call must not need actions permission") + } +} + +const ( + latestTag = "v1.4.0" + tagSHA = "0123456789abcdef0123456789abcdef01234567" +) + +// fakeGH mimics `gh api ... --jq .` for releases/latest and tag commits. +// releases/latest never returns drafts or prereleases, so those tags simply +// differ from latestTag. +const fakeGH = `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$GH_LOG" +case "$2" in + */releases/latest) [ -n "${FAKE_LATEST:-}" ] || exit 1; echo "$FAKE_LATEST" ;; + */commits/refs/tags/*) [ -n "${FAKE_SHA:-}" ] || exit 1; echo "$FAKE_SHA" ;; + *) exit 1 ;; +esac +` + +func TestDocsResolveScript(t *testing.T) { + t.Parallel() + + var script string + for _, step := range loadWorkflow(t, "docs.yaml").Jobs["resolve"].Steps { + if step.ID == "validate" { + script = step.Run + } + } + + if script == "" { + t.Fatal("validate step not found") + } + + cases := []struct { + name string + event string + tag string + ref string + latest string + sha string + wantErr bool + wantTag string + }{ + {name: "published latest", event: "release", tag: latestTag, wantTag: latestTag}, + {name: "release prerelease skipped", event: "release", tag: "v1.5.0-rc.1"}, + {name: "release old stable skipped", event: "release", tag: "v1.3.9"}, + {name: "dispatch latest", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", wantTag: latestTag}, + {name: "call from workflow_run", event: "workflow_run", tag: latestTag, ref: "refs/heads/main", wantTag: latestTag}, + {name: "dispatch off main", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/feature", wantErr: true}, + {name: "dispatch old stable", event: "workflow_dispatch", tag: "v1.3.9", ref: "refs/heads/main", wantErr: true}, + {name: "dispatch draft or unknown", event: "workflow_dispatch", tag: "v9.9.9", ref: "refs/heads/main", wantErr: true}, + {name: "dispatch prerelease", event: "workflow_dispatch", tag: "v1.4.0-rc.1", ref: "refs/heads/main", wantErr: true}, + {name: "dispatch empty", event: "workflow_dispatch", tag: "", ref: "refs/heads/main", wantErr: true}, + {name: "injection", event: "workflow_dispatch", tag: "v1.4.0\";touch pwned;\"", ref: "refs/heads/main", wantErr: true}, + {name: "newline injection", event: "workflow_dispatch", tag: "v1.4.0\nsha=evil", ref: "refs/heads/main", wantErr: true}, + {name: "no latest release", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", latest: "-", wantErr: true}, + {name: "bad sha", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", sha: "nope", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := os.MkdirAll("../../tmp", 0o755); err != nil { + t.Fatal(err) + } + + dir, err := os.MkdirTemp("../../tmp", "docs-resolve-") + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + dir, err = filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fakeGH), 0o755); err != nil { + t.Fatal(err) + } + + latest := tc.latest + if latest == "" { + latest = latestTag + } else if latest == "-" { + latest = "" + } + + sha := tc.sha + if sha == "" { + sha = tagSHA + } + + output := filepath.Join(dir, "output") + inputTag, eventTag := tc.tag, "" + if tc.event == "release" { + inputTag, eventTag = "", tc.tag + } + + cmd := exec.Command("bash", "-c", script) + cmd.Dir = dir + cmd.Env = []string{ + "PATH=" + dir + ":" + os.Getenv("PATH"), + "GITHUB_OUTPUT=" + output, + "GITHUB_REPOSITORY=Azure/unbounded", + "GH_LOG=" + filepath.Join(dir, "gh.log"), + "EVENT_NAME=" + tc.event, + "EVENT_TAG=" + eventTag, + "INPUT_TAG=" + inputTag, + "RUN_REF=" + tc.ref, + "FAKE_LATEST=" + latest, + "FAKE_SHA=" + sha, + } + + out, err := cmd.CombinedOutput() + if tc.wantErr != (err != nil) { + t.Fatalf("err = %v, wantErr %v\n%s", err, tc.wantErr, out) + } + + if _, err := os.Stat(filepath.Join(dir, "pwned")); err == nil { + t.Fatal("tag was executed as shell") + } + + got, _ := os.ReadFile(output) + want := "" + if tc.wantTag != "" { + want = "tag=" + tc.wantTag + "\nsha=" + tagSHA + "\n" + } + + if string(got) != want { + t.Fatalf("outputs = %q, want %q\n%s", got, want, out) + } + }) + } +} From b7e1b1499ad90e52e85022229c0635edbe6ee26e Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 16:42:44 +0000 Subject: [PATCH 02/10] Harden docs release validation and preserve workflow status literals --- .github/workflows/docs.yaml | 4 ++-- .golangci.yaml | 6 ++++++ hack/release/docs_workflow_test.go | 18 +++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 3da07b43d9..d81bc584a1 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -97,7 +97,7 @@ jobs: fi # Stable semver only: no prerelease suffix, no shell metacharacters. - if ! printf '%s' "$TAG" | grep -Eqx 'v[0-9]+\.[0-9]+\.[0-9]+'; then + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then reject "'${TAG}' is not a stable release tag" fi @@ -112,7 +112,7 @@ jobs: # validation. SHA="$(gh api "repos/${GITHUB_REPOSITORY}/commits/refs/tags/${TAG}" --jq .sha)" \ || reject "could not resolve ${TAG} to a commit" - if ! printf '%s' "$SHA" | grep -Eqx '[0-9a-f]{40}'; then + if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then reject "${TAG} resolved to an invalid commit '${SHA}'" fi diff --git a/.golangci.yaml b/.golangci.yaml index 2dfcfb1ecf..38b4e3c573 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -143,6 +143,12 @@ linters: linters: - misspell text: '`cancelled` is a misspelling' + # GitHub Actions spells its status function `cancelled()`. Preserve the + # literal when testing workflow conditions. + - path: hack/release/docs_workflow_test\.go + linters: + - misspell + text: '`cancelled` is a misspelling' paths: - examples$ - hack/scratch diff --git a/hack/release/docs_workflow_test.go b/hack/release/docs_workflow_test.go index 0b96dab805..98cf6dd488 100644 --- a/hack/release/docs_workflow_test.go +++ b/hack/release/docs_workflow_test.go @@ -4,11 +4,13 @@ package release import ( + "context" "os" "os/exec" "path/filepath" "strings" "testing" + "time" "gopkg.in/yaml.v3" ) @@ -164,6 +166,7 @@ func TestReleaseUpgradeCallsDocs(t *testing.T) { t.Parallel() wf := loadWorkflow(t, "release-upgrade.yaml") + docs, ok := wf.Jobs["docs"] if !ok { t.Fatal("release-upgrade must call docs") @@ -175,6 +178,7 @@ func TestReleaseUpgradeCallsDocs(t *testing.T) { } needs, _ := docs.Needs.([]any) + got := []string{} for _, n := range needs { got = append(got, n.(string)) @@ -227,6 +231,7 @@ func TestDocsResolveScript(t *testing.T) { t.Parallel() var script string + for _, step := range loadWorkflow(t, "docs.yaml").Jobs["resolve"].Steps { if step.ID == "validate" { script = step.Run @@ -261,6 +266,7 @@ func TestDocsResolveScript(t *testing.T) { {name: "newline injection", event: "workflow_dispatch", tag: "v1.4.0\nsha=evil", ref: "refs/heads/main", wantErr: true}, {name: "no latest release", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", latest: "-", wantErr: true}, {name: "bad sha", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", sha: "nope", wantErr: true}, + {name: "multiline sha", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", sha: tagSHA + "\ntag=evil", wantErr: true}, } for _, tc := range cases { @@ -288,9 +294,10 @@ func TestDocsResolveScript(t *testing.T) { } latest := tc.latest - if latest == "" { + switch latest { + case "": latest = latestTag - } else if latest == "-" { + case "-": latest = "" } @@ -300,12 +307,16 @@ func TestDocsResolveScript(t *testing.T) { } output := filepath.Join(dir, "output") + inputTag, eventTag := tc.tag, "" if tc.event == "release" { inputTag, eventTag = "", tc.tag } - cmd := exec.Command("bash", "-c", script) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "-c", script) cmd.Dir = dir cmd.Env = []string{ "PATH=" + dir + ":" + os.Getenv("PATH"), @@ -330,6 +341,7 @@ func TestDocsResolveScript(t *testing.T) { } got, _ := os.ReadFile(output) + want := "" if tc.wantTag != "" { want = "tag=" + tc.wantTag + "\nsha=" + tagSHA + "\n" From 28ab3e2849f0326be03f51000b47706092d4b435 Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 16:42:44 +0000 Subject: [PATCH 03/10] Add optional main-branch docs staging in a separate Pages repository --- .github/docs-deployment.md | 63 ++++++ .github/workflows/docs-staging.yaml | 80 +++++++ docs/content/guides/gantry.md | 2 +- docs/hugo.toml | 1 + docs/layouts/partials/head.html | 1 + hack/release/docs_staging_workflow_test.go | 245 +++++++++++++++++++++ 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 .github/docs-deployment.md create mode 100644 .github/workflows/docs-staging.yaml create mode 100644 hack/release/docs_staging_workflow_test.go diff --git a/.github/docs-deployment.md b/.github/docs-deployment.md new file mode 100644 index 0000000000..e70e81dcf0 --- /dev/null +++ b/.github/docs-deployment.md @@ -0,0 +1,63 @@ +# Docs deployment + +## Production + +`docs.yaml` deploys the docs from the latest published stable release tag, not +from `main`. Drafts, prereleases, and older releases do not replace production. +Publishing a release by hand triggers the workflow. The release upgrade workflow +calls it after publishing a release because events created with `GITHUB_TOKEN` +do not start new release workflows. + +Keep this repository's Pages source set to **GitHub Actions**. In the +`github-pages` environment, allow the `main` branch and tags matching `v*`. +Automated releases run the workflow from `main`; release events run from a tag. +Keep any existing required reviewers. + +To retry a docs deployment after fixing a failure, run **unbounded docs** from +`main` and enter the latest published stable release's tag. For example: + +```sh +gh workflow run docs.yaml --ref main -f tag=v0.10.0 +``` + +This still builds from the release tag. The workflow checks the release before +building, so a retry cannot deploy unpublished changes from `main`. + +## Staging + +GitHub Pages supports [one site per repository](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages). +The `docs-staging.yaml` workflow can publish `main` to a separate repository. +It is disabled until `DOCS_STAGING_REPOSITORY` is set and never uses this +repository's production Pages deployment. + +To enable it: + +1. Create a repository just for the staging site, such as + `Azure/unbounded-docs-staging`. Its `gh-pages` branch will contain only generated + site files. Each deployment replaces those files. +2. Create an SSH deploy key. Add the public key to the staging repository with + **Allow write access** enabled. Do not reuse a key that can write to production. +3. Create a `docs-staging` environment in this repository, limited to the `main` + branch. Store the private key there as `DOCS_STAGING_DEPLOY_KEY`. +4. Set these **repository variables** in this repository: + + | Variable | Example | + | --- | --- | + | `DOCS_STAGING_REPOSITORY` | `Azure/unbounded-docs-staging` | + | `DOCS_STAGING_BASE_URL` | `https://azure.github.io/unbounded-docs-staging/` | + + Use the staging site's actual HTTPS URL, including its path and trailing slash. +5. Run **unbounded docs staging** from `main` to create the `gh-pages` branch. + In the staging repository's Pages settings, select **Deploy from a branch**, + then `gh-pages` and `/ (root)`. Run the workflow again if needed to start the + first Pages build. + +Later docs changes on `main` update staging automatically. You can also run the +workflow from `main` after changing its settings. The deploy action adds +`.nojekyll`, so Pages serves the Hugo output without running Jekyll. + +Staging page titles include `(staging)` and pages ask search engines not to index +them. This is not access control; assume the site is public. Staging uses a +separate deploy key and concurrency group, so it cannot replace or cancel a +production deployment. Remove `DOCS_STAGING_REPOSITORY` to disable new staging +runs; this does not remove a site that is already published. diff --git a/.github/workflows/docs-staging.yaml b/.github/workflows/docs-staging.yaml new file mode 100644 index 0000000000..0e340dd0e0 --- /dev/null +++ b/.github/workflows/docs-staging.yaml @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: Apache-2.0 + +name: unbounded docs staging + +on: + push: + branches: [main] + paths: + - "docs/**" + - ".github/workflows/docs-staging.yaml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-staging + cancel-in-progress: false + +jobs: + deploy: + if: github.ref == 'refs/heads/main' && vars.DOCS_STAGING_REPOSITORY != '' + runs-on: ubuntu-latest + environment: + name: docs-staging + url: ${{ vars.DOCS_STAGING_BASE_URL }} + env: + STAGING_REPOSITORY: ${{ vars.DOCS_STAGING_REPOSITORY }} + STAGING_BASE_URL: ${{ vars.DOCS_STAGING_BASE_URL }} + steps: + - name: Validate staging settings + run: | + set -euo pipefail + if [[ ! "$STAGING_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || + [[ "${STAGING_REPOSITORY,,}" == "${GITHUB_REPOSITORY,,}" ]]; then + echo "::error::DOCS_STAGING_REPOSITORY must name a separate owner/repository" + exit 1 + fi + if [[ ! "$STAGING_BASE_URL" =~ ^https://[^/[:space:]]+/([^[:space:]]*/)?$ ]]; then + echo "::error::DOCS_STAGING_BASE_URL must be an HTTPS URL with a trailing slash" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1 + with: + hugo-version: "latest" + extended: true + + - name: Build staging docs + env: + HUGO_TITLE: "Unbounded (staging)" + HUGO_PARAMS_NOINDEX: "true" + run: hugo --minify --baseURL "$STAGING_BASE_URL" + working-directory: docs + + - name: Build search index + run: npx --yes pagefind --site public + working-directory: docs + + - name: Require staging deploy key + env: + DEPLOY_KEY: ${{ secrets.DOCS_STAGING_DEPLOY_KEY }} + run: | + if [[ -z "$DEPLOY_KEY" ]]; then + echo "::error::DOCS_STAGING_DEPLOY_KEY is required to deploy staging docs" + exit 1 + fi + + - name: Publish to staging repository + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 + with: + deploy_key: ${{ secrets.DOCS_STAGING_DEPLOY_KEY }} + external_repository: ${{ vars.DOCS_STAGING_REPOSITORY }} + publish_branch: gh-pages + publish_dir: docs/public diff --git a/docs/content/guides/gantry.md b/docs/content/guides/gantry.md index ed563f286d..342d09aa09 100644 --- a/docs/content/guides/gantry.md +++ b/docs/content/guides/gantry.md @@ -35,7 +35,7 @@ set used 99.2% less ACR Private Endpoint traffic than the direct-origin reference set. Mean pod-start P95 was 24.5% lower. Every Gantry run completed all 1,000 pods. -See [Gantry Performance at Scale](/guides/gantry-benchmark/) for raw run values, +See [Gantry Performance at Scale]({{< relref "gantry-benchmark" >}}) for raw run values, methodology, calculations, and limitations. ## Design principles diff --git a/docs/hugo.toml b/docs/hugo.toml index 03714e7259..39d2afed21 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -15,6 +15,7 @@ title = "Unbounded" ordered = false [params] + noindex = false description = "Run nodes across multiple cloud providers and bare-metal infrastructure to form a unified Kubernetes cluster, no matter where your infrastructure lives." repo = "https://github.com/Azure/unbounded" diff --git a/docs/layouts/partials/head.html b/docs/layouts/partials/head.html index 9a70eb5dd3..b3680f0a64 100644 --- a/docs/layouts/partials/head.html +++ b/docs/layouts/partials/head.html @@ -2,6 +2,7 @@ {{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }} +{{ if .Site.Params.noindex }}{{ end }} diff --git a/hack/release/docs_staging_workflow_test.go b/hack/release/docs_staging_workflow_test.go new file mode 100644 index 0000000000..207cc96b20 --- /dev/null +++ b/hack/release/docs_staging_workflow_test.go @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +package release + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +type docsStagingStep struct { + Name string `yaml:"name"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + Env map[string]string `yaml:"env"` + With map[string]string `yaml:"with"` +} + +type docsStagingWorkflow struct { + On struct { + Push struct { + Branches []string `yaml:"branches"` + } `yaml:"push"` + } `yaml:"on"` + Permissions map[string]string `yaml:"permissions"` + Jobs map[string]struct { + If string `yaml:"if"` + Permissions map[string]string `yaml:"permissions"` + Env map[string]string `yaml:"env"` + Steps []docsStagingStep `yaml:"steps"` + } `yaml:"jobs"` +} + +func loadDocsStagingWorkflow(t *testing.T) docsStagingWorkflow { + t.Helper() + + data, err := os.ReadFile("../../.github/workflows/docs-staging.yaml") + if err != nil { + t.Fatal(err) + } + + var wf docsStagingWorkflow + if err := yaml.Unmarshal(data, &wf); err != nil { + t.Fatal(err) + } + + if _, ok := wf.Jobs["deploy"]; !ok { + t.Fatal("docs-staging workflow has no deploy job") + } + + return wf +} + +func docsStagingStepNamed(t *testing.T, wf docsStagingWorkflow, name string) docsStagingStep { + t.Helper() + + for _, s := range wf.Jobs["deploy"].Steps { + if s.Name == name { + return s + } + } + + t.Fatalf("deploy job has no step %q", name) + + return docsStagingStep{} +} + +func TestDocsStagingIsMainOnlyAndOptional(t *testing.T) { + t.Parallel() + + wf := loadDocsStagingWorkflow(t) + + if got := strings.Join(wf.On.Push.Branches, ","); got != "main" { + t.Fatalf("push branches = %q, want only main", got) + } + + cond := wf.Jobs["deploy"].If + for _, want := range []string{"github.ref == 'refs/heads/main'", "vars.DOCS_STAGING_REPOSITORY != ''"} { + if !strings.Contains(cond, want) { + t.Fatalf("deploy condition missing %q:\n%s", want, cond) + } + } +} + +func TestDocsStagingCannotPublishToProductionPages(t *testing.T) { + t.Parallel() + + wf := loadDocsStagingWorkflow(t) + job := wf.Jobs["deploy"] + + for _, perms := range []map[string]string{wf.Permissions, job.Permissions} { + if perms["pages"] != "" || perms["id-token"] != "" { + t.Fatalf("staging workflow must not request pages/id-token permissions: %v", perms) + } + + if p := perms["contents"]; p != "" && p != "read" { + t.Fatalf("contents permission = %q, want read", p) + } + } + + var publish *docsStagingStep + + for i, s := range job.Steps { + if strings.Contains(s.Uses, "deploy-pages") || strings.Contains(s.Uses, "upload-pages-artifact") { + t.Fatalf("staging workflow uses production Pages action %q", s.Uses) + } + + if strings.HasPrefix(s.Uses, "peaceiris/actions-gh-pages@") { + publish = &job.Steps[i] + } + } + + if publish == nil { + t.Fatal("no actions-gh-pages publish step") + } + + if publish.With["external_repository"] != "${{ vars.DOCS_STAGING_REPOSITORY }}" { + t.Fatalf("publish must target the staging repository, got %q", publish.With["external_repository"]) + } + + if publish.With["deploy_key"] != "${{ secrets.DOCS_STAGING_DEPLOY_KEY }}" { + t.Fatalf("publish must use the staging deploy key, got %q", publish.With["deploy_key"]) + } + + if publish.With["github_token"] != "" || publish.With["personal_token"] != "" { + t.Fatal("publish must not use the workflow token or a personal token") + } +} + +func TestDocsStagingBuildsWithProjectBaseURLAndNoindex(t *testing.T) { + t.Parallel() + + wf := loadDocsStagingWorkflow(t) + + if got := wf.Jobs["deploy"].Env["STAGING_BASE_URL"]; got != "${{ vars.DOCS_STAGING_BASE_URL }}" { + t.Fatalf("STAGING_BASE_URL = %q", got) + } + + build := docsStagingStepNamed(t, wf, "Build staging docs") + if !strings.Contains(build.Run, `--baseURL "$STAGING_BASE_URL"`) { + t.Fatalf("build does not use the staging base URL:\n%s", build.Run) + } + + if build.Env["HUGO_PARAMS_NOINDEX"] != "true" { + t.Fatalf("HUGO_PARAMS_NOINDEX = %q, want true", build.Env["HUGO_PARAMS_NOINDEX"]) + } +} + +// runDocsStagingStep runs a step's bash script with only the given env, so no +// real secret or remote is ever reachable. +func runDocsStagingStep(t *testing.T, script string, env map[string]string) (string, int) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "-c", script) + cmd.Env = []string{"PATH=" + os.Getenv("PATH")} + + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + out, err := cmd.CombinedOutput() + if err == nil { + return string(out), 0 + } + + if exit, ok := err.(*exec.ExitError); ok && ctx.Err() == nil { + return string(out), exit.ExitCode() + } + + t.Fatalf("running step: %v\n%s", err, out) + + return "", -1 +} + +func TestDocsStagingValidatesSettings(t *testing.T) { + t.Parallel() + requireBash4(t) + + script := docsStagingStepNamed(t, loadDocsStagingWorkflow(t), "Validate staging settings").Run + + const ( + repo = "example/unbounded-staging" + url = "https://example.github.io/unbounded-staging/" + ) + + for _, tc := range []struct { + name, repo, url, wantErr string + }{ + {name: "project URL", repo: repo, url: url}, + {name: "root URL", repo: repo, url: "https://staging.example.com/"}, + {name: "no repository", repo: "", url: url, wantErr: "DOCS_STAGING_REPOSITORY"}, + {name: "same repository", repo: "Microsoft/Unbounded", url: url, wantErr: "DOCS_STAGING_REPOSITORY"}, + {name: "malformed repository", repo: "example/x/y", url: url, wantErr: "DOCS_STAGING_REPOSITORY"}, + {name: "no URL", repo: repo, url: "", wantErr: "DOCS_STAGING_BASE_URL"}, + {name: "HTTP URL", repo: repo, url: "http://example.github.io/unbounded-staging/", wantErr: "DOCS_STAGING_BASE_URL"}, + {name: "missing slash", repo: repo, url: "https://example.github.io/unbounded-staging", wantErr: "DOCS_STAGING_BASE_URL"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, code := runDocsStagingStep(t, script, map[string]string{ + "GITHUB_REPOSITORY": "microsoft/unbounded", + "STAGING_REPOSITORY": tc.repo, + "STAGING_BASE_URL": tc.url, + }) + if tc.wantErr == "" { + if code != 0 { + t.Fatalf("exit %d, want 0:\n%s", code, out) + } + + return + } + + if code == 0 || !strings.Contains(out, "::error::"+tc.wantErr) { + t.Fatalf("exit %d, want error about %s:\n%s", code, tc.wantErr, out) + } + }) + } +} + +func TestDocsStagingRequiresDeployKey(t *testing.T) { + t.Parallel() + requireBash4(t) + + script := docsStagingStepNamed(t, loadDocsStagingWorkflow(t), "Require staging deploy key").Run + + out, code := runDocsStagingStep(t, script, map[string]string{"DEPLOY_KEY": "placeholder-not-a-key"}) + if code != 0 || strings.Contains(out, "placeholder") { + t.Fatalf("present key: exit %d, output %q", code, out) + } + + out, code = runDocsStagingStep(t, script, nil) + if code == 0 || !strings.Contains(out, "::error::DOCS_STAGING_DEPLOY_KEY") { + t.Fatalf("absent key: exit %d:\n%s", code, out) + } +} From 99949d549667cdc15459f460cdefaee73245ac8e Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 16:50:19 +0000 Subject: [PATCH 04/10] docs: preserve custom domain on staging deploys --- .github/docs-deployment.md | 6 ++++++ .github/workflows/docs-staging.yaml | 2 ++ hack/release/docs_staging_workflow_test.go | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/.github/docs-deployment.md b/.github/docs-deployment.md index e70e81dcf0..9d63a2e2e1 100644 --- a/.github/docs-deployment.md +++ b/.github/docs-deployment.md @@ -47,6 +47,12 @@ To enable it: | `DOCS_STAGING_BASE_URL` | `https://azure.github.io/unbounded-docs-staging/` | Use the staging site's actual HTTPS URL, including its path and trailing slash. + + For a custom domain, also set `DOCS_STAGING_CNAME` to the bare host name, + such as `staging.example.com`, and use `https://staging.example.com/` as the + base URL. Each deploy replaces the `CNAME` file, so a domain set only in the + staging repository's Pages settings is lost on the next deploy. Leave + `DOCS_STAGING_CNAME` unset for a `github.io` URL. 5. Run **unbounded docs staging** from `main` to create the `gh-pages` branch. In the staging repository's Pages settings, select **Deploy from a branch**, then `gh-pages` and `/ (root)`. Run the workflow again if needed to start the diff --git a/.github/workflows/docs-staging.yaml b/.github/workflows/docs-staging.yaml index 0e340dd0e0..45c1eed55f 100644 --- a/.github/workflows/docs-staging.yaml +++ b/.github/workflows/docs-staging.yaml @@ -78,3 +78,5 @@ jobs: external_repository: ${{ vars.DOCS_STAGING_REPOSITORY }} publish_branch: gh-pages publish_dir: docs/public + # Each deploy replaces CNAME. Leave the variable unset for github.io URLs. + cname: ${{ vars.DOCS_STAGING_CNAME }} diff --git a/hack/release/docs_staging_workflow_test.go b/hack/release/docs_staging_workflow_test.go index 207cc96b20..abffb40aae 100644 --- a/hack/release/docs_staging_workflow_test.go +++ b/hack/release/docs_staging_workflow_test.go @@ -128,6 +128,11 @@ func TestDocsStagingCannotPublishToProductionPages(t *testing.T) { t.Fatalf("publish must use the staging deploy key, got %q", publish.With["deploy_key"]) } + // An unset variable expands to an empty input, so no CNAME is written. + if publish.With["cname"] != "${{ vars.DOCS_STAGING_CNAME }}" { + t.Fatalf("publish cname = %q, want the DOCS_STAGING_CNAME variable", publish.With["cname"]) + } + if publish.With["github_token"] != "" || publish.With["personal_token"] != "" { t.Fatal("publish must not use the workflow token or a personal token") } From e0f05420740612d8da64e441a887f625f36df736 Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 16:50:13 +0000 Subject: [PATCH 05/10] Fail docs resolve on GitHub API errors for release events --- .github/workflows/docs.yaml | 8 +++++--- hack/release/docs_workflow_test.go | 8 +++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index d81bc584a1..899c3315ec 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -77,6 +77,7 @@ jobs: # Ineligible human-published releases (prereleases, old-series # patches) are skipped quietly; an explicit request that cannot be # honored fails, so a broken retry or automation is visible. + # API failures and invalid responses fail for every event. reject() { if [ "$EVENT_NAME" = "release" ]; then echo "::notice::Skipping docs: $1" @@ -103,7 +104,7 @@ jobs: # releases/latest returns only published, non-prerelease releases. LATEST_TAG="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)" \ - || reject "could not look up the latest release" + || { echo "::error::could not look up the latest release"; exit 1; } if [ "$TAG" != "$LATEST_TAG" ]; then reject "${TAG} is not the latest published release (${LATEST_TAG})" fi @@ -111,9 +112,10 @@ jobs: # Pin the commit now so build cannot pick up a tag moved after # validation. SHA="$(gh api "repos/${GITHUB_REPOSITORY}/commits/refs/tags/${TAG}" --jq .sha)" \ - || reject "could not resolve ${TAG} to a commit" + || { echo "::error::could not resolve ${TAG} to a commit"; exit 1; } if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then - reject "${TAG} resolved to an invalid commit '${SHA}'" + echo "::error::${TAG} resolved to an invalid commit '${SHA}'" + exit 1 fi echo "tag=${TAG}" >> "$GITHUB_OUTPUT" diff --git a/hack/release/docs_workflow_test.go b/hack/release/docs_workflow_test.go index 98cf6dd488..fa9e0dec96 100644 --- a/hack/release/docs_workflow_test.go +++ b/hack/release/docs_workflow_test.go @@ -267,6 +267,9 @@ func TestDocsResolveScript(t *testing.T) { {name: "no latest release", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", latest: "-", wantErr: true}, {name: "bad sha", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", sha: "nope", wantErr: true}, {name: "multiline sha", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", sha: tagSHA + "\ntag=evil", wantErr: true}, + {name: "release latest lookup fails", event: "release", tag: latestTag, latest: "-", wantErr: true}, + {name: "release commit lookup fails", event: "release", tag: latestTag, sha: "-", wantErr: true}, + {name: "release bad sha", event: "release", tag: latestTag, sha: "nope", wantErr: true}, } for _, tc := range cases { @@ -302,8 +305,11 @@ func TestDocsResolveScript(t *testing.T) { } sha := tc.sha - if sha == "" { + switch sha { + case "": sha = tagSHA + case "-": + sha = "" } output := filepath.Join(dir, "output") From 40ee3f446049de070856d78524b6950b36d57a6e Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 16:51:04 +0000 Subject: [PATCH 06/10] release: dispatch docs workflow instead of reusable call A reusable call held the deploy-unbounded-stable concurrency group while the github-pages environment awaited approval, blocking later cluster upgrades. Dispatch main's docs.yaml with only actions: write instead. --- .github/docs-deployment.md | 9 ++- .github/workflows/docs.yaml | 22 ++---- .github/workflows/release-upgrade.yaml | 25 ++++--- hack/release/docs_workflow_test.go | 98 +++++++++++++++++++++++--- 4 files changed, 115 insertions(+), 39 deletions(-) diff --git a/.github/docs-deployment.md b/.github/docs-deployment.md index 9d63a2e2e1..dc423451e0 100644 --- a/.github/docs-deployment.md +++ b/.github/docs-deployment.md @@ -4,9 +4,12 @@ `docs.yaml` deploys the docs from the latest published stable release tag, not from `main`. Drafts, prereleases, and older releases do not replace production. -Publishing a release by hand triggers the workflow. The release upgrade workflow -calls it after publishing a release because events created with `GITHUB_TOKEN` -do not start new release workflows. +Publishing a release by hand triggers the workflow. After the release upgrade +workflow publishes a release, it dispatches the workflow on `main` because +releases created with `GITHUB_TOKEN` do not trigger `release` workflows. +Dispatching with `GITHUB_TOKEN` is allowed. The docs deployment runs as its own +workflow, so waiting for `github-pages` approval does not delay later cluster +upgrades. Keep this repository's Pages source set to **GitHub Actions**. In the `github-pages` environment, allow the `main` branch and tags matching `v*`. diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 899c3315ec..d8b2fe786f 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -13,11 +13,9 @@ name: unbounded docs # release: published a release published by a human. GitHub suppresses # this event for releases published with GITHUB_TOKEN, # so the automated publish path cannot rely on it. -# workflow_call release-upgrade.yaml after it publishes a Latest -# release. A reusable call rather than a dispatch, so -# the caller needs no actions: write and older tags -# never need to know about this workflow's inputs. -# workflow_dispatch manual retry for a given tag, from main only. +# workflow_dispatch release-upgrade.yaml after it publishes a Latest +# release, or a manual retry for a given tag. Runs +# from main only. # # Whatever the trigger, `resolve` checks the tag against the repository's # latest published release before anything is checked out. Drafts and @@ -33,13 +31,6 @@ on: description: "Release tag to publish docs from; must be the latest published stable release" required: true type: string - workflow_call: - inputs: - tag: - description: "Release tag to publish docs from; must be the latest published stable release" - required: true - type: string - permissions: contents: read @@ -59,10 +50,9 @@ jobs: # Inputs reach the shell only through env vars, never through expression # interpolation into the script, so a crafted tag cannot inject commands. # - # In a reusable workflow github.event_name and github.ref are the - # caller's, so only the release event is treated specially: every other - # event, including release-upgrade's workflow_run, must supply a tag and - # run on main. + # Only the release event is treated specially: every other event, + # including release-upgrade's dispatch, must supply a tag and run on + # main. - name: Validate tag id: validate env: diff --git a/.github/workflows/release-upgrade.yaml b/.github/workflows/release-upgrade.yaml index ce098c006d..b83b50df17 100644 --- a/.github/workflows/release-upgrade.yaml +++ b/.github/workflows/release-upgrade.yaml @@ -1152,9 +1152,13 @@ jobs: # Publish the production docs site from the release that just became Latest. # # Releases published with GITHUB_TOKEN do not emit the `release` event, so - # docs.yaml's own trigger never fires for this path. A reusable call keeps the - # caller free of actions: write and works no matter which tag is deployed, - # since the called workflow comes from this run's commit, not the tag. + # docs.yaml's own trigger never fires for this path. Dispatching docs.yaml + # instead is exempt from that rule: GITHUB_TOKEN may start workflow_dispatch + # runs. It is a dispatch rather than a reusable call so the Pages deploy, + # and any approval wait on the github-pages environment, runs outside this + # workflow's deploy-unbounded-stable concurrency group and never blocks the + # next cluster upgrade. `--ref main` runs main's docs.yaml, not the copy + # stored in the tag. # # Either publish path counts; they are mutually exclusive. Non-Latest # releases (prereleases, older-series patches) never replace the site. @@ -1167,13 +1171,16 @@ jobs: needs.resolve.result == 'success' && needs.resolve.outputs.latest == 'true' && (needs.publish.result == 'success' || needs.publish-forced.result == 'success') + runs-on: ubuntu-latest permissions: - contents: read - pages: write - id-token: write - uses: ./.github/workflows/docs.yaml - with: - tag: ${{ needs.resolve.outputs.tag }} + actions: write + steps: + - name: Dispatch docs workflow + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.resolve.outputs.tag }} + run: gh workflow run docs.yaml --ref main -f tag="$TAG" # --------------------------------------------------------------------------- diff --git a/hack/release/docs_workflow_test.go b/hack/release/docs_workflow_test.go index fa9e0dec96..3a65d6da43 100644 --- a/hack/release/docs_workflow_test.go +++ b/hack/release/docs_workflow_test.go @@ -81,7 +81,11 @@ func TestDocsWorkflowTriggers(t *testing.T) { t.Fatalf("release types = %v, want [published]", release.Types) } - for _, event := range []string{"workflow_dispatch", "workflow_call"} { + if _, ok := wf.On["workflow_call"]; ok { + t.Fatal("docs must not be a reusable workflow; release-upgrade dispatches it") + } + + for _, event := range []string{"workflow_dispatch"} { var trig struct { Inputs map[string]struct { Required bool `yaml:"required"` @@ -162,19 +166,20 @@ func TestDocsWorkflowPermissionsAndOrdering(t *testing.T) { } } -func TestReleaseUpgradeCallsDocs(t *testing.T) { +func TestReleaseUpgradeDispatchesDocs(t *testing.T) { t.Parallel() wf := loadWorkflow(t, "release-upgrade.yaml") docs, ok := wf.Jobs["docs"] if !ok { - t.Fatal("release-upgrade must call docs") + t.Fatal("release-upgrade must dispatch docs") } - if docs.Uses != "./.github/workflows/docs.yaml" || - docs.With["tag"] != "${{ needs.resolve.outputs.tag }}" { - t.Fatalf("docs call = uses %q with %v", docs.Uses, docs.With) + // A reusable call would hold deploy-unbounded-stable while the + // github-pages environment waits for approval. + if docs.Uses != "" { + t.Fatalf("docs must be dispatched, not called: uses %q", docs.Uses) } needs, _ := docs.Needs.([]any) @@ -199,13 +204,84 @@ func TestReleaseUpgradeCallsDocs(t *testing.T) { } } - if len(docs.Permissions) != 3 || docs.Permissions["contents"] != "read" || - docs.Permissions["pages"] != "write" || docs.Permissions["id-token"] != "write" { - t.Fatalf("docs permissions = %v", docs.Permissions) + if len(docs.Permissions) != 1 || docs.Permissions["actions"] != "write" { + t.Fatalf("docs permissions = %v, want only actions: write", docs.Permissions) + } + + if len(docs.Steps) != 1 { + t.Fatalf("docs steps = %d, want 1", len(docs.Steps)) + } + + step := docs.Steps[0] + if step.Uses != "" { + t.Fatalf("dispatch step uses %q", step.Uses) + } + + if step.Env["TAG"] != "${{ needs.resolve.outputs.tag }}" || + step.Env["GH_TOKEN"] != "${{ github.token }}" || + step.Env["GH_REPO"] != "${{ github.repository }}" { + t.Fatalf("dispatch env = %v", step.Env) + } + + if strings.Contains(step.Run, "${{") { + t.Fatalf("dispatch script interpolates expressions; use env vars:\n%s", step.Run) + } + + runDispatch(t, step.Run) +} + +// runDispatch runs the dispatch script against a fake gh and checks that it +// targets main's docs.yaml with the resolved tag, passed as one argument. +func runDispatch(t *testing.T, script string) { + t.Helper() + + if err := os.MkdirAll("../../tmp", 0o755); err != nil { + t.Fatal(err) + } + + dir, err := os.MkdirTemp("../../tmp", "docs-dispatch-") + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + dir, err = filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + + gh := "#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > \"$GH_LOG\"\n" + if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(gh), 0o755); err != nil { + t.Fatal(err) + } + + log := filepath.Join(dir, "gh.log") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "-euo", "pipefail", "-c", script) + cmd.Env = []string{ + "PATH=" + dir + ":" + os.Getenv("PATH"), + "GH_LOG=" + log, + "GH_TOKEN=token", + "GH_REPO=Azure/unbounded", + "TAG=v1.4.0 extra", + } + + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("dispatch failed: %v\n%s", err, out) + } + + data, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) } - if _, ok := docs.Permissions["actions"]; ok { - t.Fatal("docs call must not need actions permission") + want := "workflow\nrun\ndocs.yaml\n--ref\nmain\n-f\ntag=v1.4.0 extra\n" + if string(data) != want { + t.Fatalf("gh args = %q, want %q", data, want) } } From 6b803ab23d781bee31e5feaac884f0e3fc7ea28e Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 17:03:18 +0000 Subject: [PATCH 07/10] docs: hold pages concurrency only in deploy and recheck latest tag --- .github/workflows/docs.yaml | 41 +++++++-- hack/release/docs_workflow_test.go | 131 ++++++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index d8b2fe786f..1a257719f7 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -20,6 +20,11 @@ name: unbounded docs # Whatever the trigger, `resolve` checks the tag against the repository's # latest published release before anything is checked out. Drafts and # prereleases are never returned by releases/latest, so they cannot pass. +# +# Only `deploy` holds the `pages` concurrency group, so an ineligible run +# exits in `resolve` without replacing a queued valid run. A newer release +# can still finish first, so `deploy` checks the tag again once it holds the +# group and skips publishing if a newer release has become the latest. # --------------------------------------------------------------------------- on: @@ -34,10 +39,6 @@ on: permissions: contents: read -concurrency: - group: pages - cancel-in-progress: false - jobs: resolve: runs-on: ubuntu-latest @@ -153,14 +154,44 @@ jobs: path: docs/public deploy: - needs: build + needs: [resolve, build] + if: needs.resolve.outputs.tag != '' runs-on: ubuntu-latest + # Never cancel: a stale run must not abort a deployment in progress. + concurrency: + group: pages + cancel-in-progress: false permissions: + contents: read pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: + # Queued and approval-gated runs can start after a newer release has + # been published. Skip if the validated tag is no longer the latest. + - name: Recheck latest release + id: recheck + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + + LATEST_TAG="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)" \ + || { echo "::error::could not look up the latest release"; exit 1; } + if [ -z "$LATEST_TAG" ]; then + echo "::error::latest release lookup returned no tag" + exit 1 + fi + if [ "$TAG" != "$LATEST_TAG" ]; then + echo "::notice::Skipping deploy: ${TAG} is no longer the latest published release (${LATEST_TAG})" + echo "current=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "current=true" >> "$GITHUB_OUTPUT" + - id: deployment + if: steps.recheck.outputs.current == 'true' uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/hack/release/docs_workflow_test.go b/hack/release/docs_workflow_test.go index 3a65d6da43..39d4f3ef32 100644 --- a/hack/release/docs_workflow_test.go +++ b/hack/release/docs_workflow_test.go @@ -17,6 +17,7 @@ import ( type docsStep struct { ID string `yaml:"id"` + If string `yaml:"if"` Name string `yaml:"name"` Uses string `yaml:"uses"` Run string `yaml:"run"` @@ -24,9 +25,15 @@ type docsStep struct { Env map[string]string `yaml:"env"` } +type docsConcurrency struct { + Group string `yaml:"group"` + CancelInProgress bool `yaml:"cancel-in-progress"` +} + type docsJob struct { Needs any `yaml:"needs"` If string `yaml:"if"` + Concurrency *docsConcurrency `yaml:"concurrency"` Uses string `yaml:"uses"` With map[string]string `yaml:"with"` Permissions map[string]string `yaml:"permissions"` @@ -36,11 +43,8 @@ type docsJob struct { type docsWorkflow struct { On map[string]yaml.Node `yaml:"on"` Permissions map[string]string `yaml:"permissions"` - Concurrency struct { - Group string `yaml:"group"` - CancelInProgress bool `yaml:"cancel-in-progress"` - } `yaml:"concurrency"` - Jobs map[string]docsJob `yaml:"jobs"` + Concurrency *docsConcurrency `yaml:"concurrency"` + Jobs map[string]docsJob `yaml:"jobs"` } func loadWorkflow(t *testing.T, name string) docsWorkflow { @@ -108,8 +112,21 @@ func TestDocsWorkflowTriggers(t *testing.T) { } } - if wf.Concurrency.Group != "pages" || wf.Concurrency.CancelInProgress { - t.Fatalf("concurrency = %+v, want pages without cancel", wf.Concurrency) + // Ineligible runs must exit before entering the pages group, so only + // deploy holds it, and it never cancels a running deployment. + if wf.Concurrency != nil { + t.Fatalf("workflow-level concurrency = %+v, want none", wf.Concurrency) + } + + for name, job := range wf.Jobs { + c := job.Concurrency + if name == "deploy" { + if c == nil || c.Group != "pages" || c.CancelInProgress { + t.Fatalf("deploy concurrency = %+v, want pages without cancel", c) + } + } else if c != nil { + t.Fatalf("%s concurrency = %+v, want none", name, c) + } } } @@ -130,13 +147,26 @@ func TestDocsWorkflowPermissionsAndOrdering(t *testing.T) { } deploy := wf.Jobs["deploy"] - if len(deploy.Permissions) != 2 || deploy.Permissions["pages"] != "write" || - deploy.Permissions["id-token"] != "write" { + if len(deploy.Permissions) != 3 || deploy.Permissions["pages"] != "write" || + deploy.Permissions["id-token"] != "write" || deploy.Permissions["contents"] != "read" { t.Fatalf("deploy permissions = %v", deploy.Permissions) } - if deploy.Needs != "build" { - t.Fatalf("deploy needs = %v, want build", deploy.Needs) + needs, _ := deploy.Needs.([]any) + if len(needs) != 2 || needs[0] != "resolve" || needs[1] != "build" || + deploy.If != "needs.resolve.outputs.tag != ''" { + t.Fatalf("deploy must wait for resolve and build: needs=%v if=%q", deploy.Needs, deploy.If) + } + + if len(deploy.Steps) != 2 || deploy.Steps[0].ID != "recheck" || + deploy.Steps[0].Env["TAG"] != "${{ needs.resolve.outputs.tag }}" || + strings.Contains(deploy.Steps[0].Run, "${{") { + t.Fatalf("deploy must recheck the validated tag via env first: %+v", deploy.Steps) + } + + if !strings.HasPrefix(deploy.Steps[1].Uses, "actions/deploy-pages@") || + deploy.Steps[1].If != "steps.recheck.outputs.current == 'true'" { + t.Fatalf("deploy-pages must run only when the tag is still current: %+v", deploy.Steps[1]) } build := wf.Jobs["build"] @@ -435,3 +465,82 @@ func TestDocsResolveScript(t *testing.T) { }) } } + +func TestDocsDeployRecheckScript(t *testing.T) { + t.Parallel() + + var script string + + for _, step := range loadWorkflow(t, "docs.yaml").Jobs["deploy"].Steps { + if step.ID == "recheck" { + script = step.Run + } + } + + if script == "" { + t.Fatal("recheck step not found") + } + + cases := []struct { + name string + latest string + wantErr bool + want string + }{ + {name: "still latest", latest: latestTag, want: "current=true\n"}, + {name: "newer release published", latest: "v1.5.0", want: "current=false\n"}, + {name: "lookup fails", latest: "", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if err := os.MkdirAll("../../tmp", 0o755); err != nil { + t.Fatal(err) + } + + dir, err := os.MkdirTemp("../../tmp", "docs-recheck-") + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + dir, err = filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fakeGH), 0o755); err != nil { + t.Fatal(err) + } + + output := filepath.Join(dir, "output") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "-c", script) + cmd.Dir = dir + cmd.Env = []string{ + "PATH=" + dir + ":" + os.Getenv("PATH"), + "GITHUB_OUTPUT=" + output, + "GITHUB_REPOSITORY=Azure/unbounded", + "GH_LOG=" + filepath.Join(dir, "gh.log"), + "TAG=" + latestTag, + "FAKE_LATEST=" + tc.latest, + } + + out, err := cmd.CombinedOutput() + if tc.wantErr != (err != nil) { + t.Fatalf("err = %v, wantErr %v\n%s", err, tc.wantErr, out) + } + + got, _ := os.ReadFile(output) + if string(got) != tc.want { + t.Fatalf("outputs = %q, want %q\n%s", got, tc.want, out) + } + }) + } +} From ad51761e9082778b637d9f193bc350ea50a88808 Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 17:07:01 +0000 Subject: [PATCH 08/10] docs: queue all pending pages deploys --- .github/actionlint.yaml | 8 ++++++++ .github/workflows/docs.yaml | 3 +++ hack/release/docs_workflow_test.go | 6 ++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 9ea9b888c9..fdbeb484f4 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -11,3 +11,11 @@ self-hosted-runner: labels: - pve - ubuntu-26.04 + +paths: + .github/workflows/docs.yaml: + ignore: + # actionlint 1.7.12 does not know `concurrency.queue`, which GitHub + # supports: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency + # hack/release/docs_workflow_test.go checks the value instead. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 1a257719f7..d0935b2600 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -158,9 +158,12 @@ jobs: if: needs.resolve.outputs.tag != '' runs-on: ubuntu-latest # Never cancel: a stale run must not abort a deployment in progress. + # queue: max keeps every pending run instead of letting a later one + # replace it, so a slow older build cannot displace a newer release. concurrency: group: pages cancel-in-progress: false + queue: max permissions: contents: read pages: write diff --git a/hack/release/docs_workflow_test.go b/hack/release/docs_workflow_test.go index 39d4f3ef32..7e10d9e0bc 100644 --- a/hack/release/docs_workflow_test.go +++ b/hack/release/docs_workflow_test.go @@ -28,6 +28,7 @@ type docsStep struct { type docsConcurrency struct { Group string `yaml:"group"` CancelInProgress bool `yaml:"cancel-in-progress"` + Queue string `yaml:"queue"` } type docsJob struct { @@ -121,8 +122,9 @@ func TestDocsWorkflowTriggers(t *testing.T) { for name, job := range wf.Jobs { c := job.Concurrency if name == "deploy" { - if c == nil || c.Group != "pages" || c.CancelInProgress { - t.Fatalf("deploy concurrency = %+v, want pages without cancel", c) + // actionlint cannot parse queue, so this is the only typo guard. + if c == nil || c.Group != "pages" || c.CancelInProgress || c.Queue != "max" { + t.Fatalf("deploy concurrency = %+v, want pages, no cancel, queue max", c) } } else if c != nil { t.Fatalf("%s concurrency = %+v, want none", name, c) From 898f7c9a57d21a36de25d75b35c0b98dcf27c486 Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 18:00:48 +0000 Subject: [PATCH 09/10] Simplify docs deployment to the release pipeline --- .github/actionlint.yaml | 8 - .github/docs-deployment.md | 72 --- .github/workflows/docs-staging.yaml | 82 --- .github/workflows/docs.yaml | 179 +----- .github/workflows/release-upgrade.yaml | 22 +- .golangci.yaml | 6 - docs/content/guides/gantry.md | 2 +- docs/hugo.toml | 1 - docs/layouts/partials/head.html | 1 - hack/release/docs_staging_workflow_test.go | 250 -------- hack/release/docs_workflow_test.go | 548 ------------------ hack/release/release_upgrade_workflow_test.go | 106 ++++ 12 files changed, 130 insertions(+), 1147 deletions(-) delete mode 100644 .github/docs-deployment.md delete mode 100644 .github/workflows/docs-staging.yaml delete mode 100644 hack/release/docs_staging_workflow_test.go delete mode 100644 hack/release/docs_workflow_test.go diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index fdbeb484f4..9ea9b888c9 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -11,11 +11,3 @@ self-hosted-runner: labels: - pve - ubuntu-26.04 - -paths: - .github/workflows/docs.yaml: - ignore: - # actionlint 1.7.12 does not know `concurrency.queue`, which GitHub - # supports: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency - # hack/release/docs_workflow_test.go checks the value instead. - - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/docs-deployment.md b/.github/docs-deployment.md deleted file mode 100644 index dc423451e0..0000000000 --- a/.github/docs-deployment.md +++ /dev/null @@ -1,72 +0,0 @@ -# Docs deployment - -## Production - -`docs.yaml` deploys the docs from the latest published stable release tag, not -from `main`. Drafts, prereleases, and older releases do not replace production. -Publishing a release by hand triggers the workflow. After the release upgrade -workflow publishes a release, it dispatches the workflow on `main` because -releases created with `GITHUB_TOKEN` do not trigger `release` workflows. -Dispatching with `GITHUB_TOKEN` is allowed. The docs deployment runs as its own -workflow, so waiting for `github-pages` approval does not delay later cluster -upgrades. - -Keep this repository's Pages source set to **GitHub Actions**. In the -`github-pages` environment, allow the `main` branch and tags matching `v*`. -Automated releases run the workflow from `main`; release events run from a tag. -Keep any existing required reviewers. - -To retry a docs deployment after fixing a failure, run **unbounded docs** from -`main` and enter the latest published stable release's tag. For example: - -```sh -gh workflow run docs.yaml --ref main -f tag=v0.10.0 -``` - -This still builds from the release tag. The workflow checks the release before -building, so a retry cannot deploy unpublished changes from `main`. - -## Staging - -GitHub Pages supports [one site per repository](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages). -The `docs-staging.yaml` workflow can publish `main` to a separate repository. -It is disabled until `DOCS_STAGING_REPOSITORY` is set and never uses this -repository's production Pages deployment. - -To enable it: - -1. Create a repository just for the staging site, such as - `Azure/unbounded-docs-staging`. Its `gh-pages` branch will contain only generated - site files. Each deployment replaces those files. -2. Create an SSH deploy key. Add the public key to the staging repository with - **Allow write access** enabled. Do not reuse a key that can write to production. -3. Create a `docs-staging` environment in this repository, limited to the `main` - branch. Store the private key there as `DOCS_STAGING_DEPLOY_KEY`. -4. Set these **repository variables** in this repository: - - | Variable | Example | - | --- | --- | - | `DOCS_STAGING_REPOSITORY` | `Azure/unbounded-docs-staging` | - | `DOCS_STAGING_BASE_URL` | `https://azure.github.io/unbounded-docs-staging/` | - - Use the staging site's actual HTTPS URL, including its path and trailing slash. - - For a custom domain, also set `DOCS_STAGING_CNAME` to the bare host name, - such as `staging.example.com`, and use `https://staging.example.com/` as the - base URL. Each deploy replaces the `CNAME` file, so a domain set only in the - staging repository's Pages settings is lost on the next deploy. Leave - `DOCS_STAGING_CNAME` unset for a `github.io` URL. -5. Run **unbounded docs staging** from `main` to create the `gh-pages` branch. - In the staging repository's Pages settings, select **Deploy from a branch**, - then `gh-pages` and `/ (root)`. Run the workflow again if needed to start the - first Pages build. - -Later docs changes on `main` update staging automatically. You can also run the -workflow from `main` after changing its settings. The deploy action adds -`.nojekyll`, so Pages serves the Hugo output without running Jekyll. - -Staging page titles include `(staging)` and pages ask search engines not to index -them. This is not access control; assume the site is public. Staging uses a -separate deploy key and concurrency group, so it cannot replace or cancel a -production deployment. Remove `DOCS_STAGING_REPOSITORY` to disable new staging -runs; this does not remove a site that is already published. diff --git a/.github/workflows/docs-staging.yaml b/.github/workflows/docs-staging.yaml deleted file mode 100644 index 45c1eed55f..0000000000 --- a/.github/workflows/docs-staging.yaml +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# SPDX-License-Identifier: Apache-2.0 - -name: unbounded docs staging - -on: - push: - branches: [main] - paths: - - "docs/**" - - ".github/workflows/docs-staging.yaml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: docs-staging - cancel-in-progress: false - -jobs: - deploy: - if: github.ref == 'refs/heads/main' && vars.DOCS_STAGING_REPOSITORY != '' - runs-on: ubuntu-latest - environment: - name: docs-staging - url: ${{ vars.DOCS_STAGING_BASE_URL }} - env: - STAGING_REPOSITORY: ${{ vars.DOCS_STAGING_REPOSITORY }} - STAGING_BASE_URL: ${{ vars.DOCS_STAGING_BASE_URL }} - steps: - - name: Validate staging settings - run: | - set -euo pipefail - if [[ ! "$STAGING_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || - [[ "${STAGING_REPOSITORY,,}" == "${GITHUB_REPOSITORY,,}" ]]; then - echo "::error::DOCS_STAGING_REPOSITORY must name a separate owner/repository" - exit 1 - fi - if [[ ! "$STAGING_BASE_URL" =~ ^https://[^/[:space:]]+/([^[:space:]]*/)?$ ]]; then - echo "::error::DOCS_STAGING_BASE_URL must be an HTTPS URL with a trailing slash" - exit 1 - fi - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1 - with: - hugo-version: "latest" - extended: true - - - name: Build staging docs - env: - HUGO_TITLE: "Unbounded (staging)" - HUGO_PARAMS_NOINDEX: "true" - run: hugo --minify --baseURL "$STAGING_BASE_URL" - working-directory: docs - - - name: Build search index - run: npx --yes pagefind --site public - working-directory: docs - - - name: Require staging deploy key - env: - DEPLOY_KEY: ${{ secrets.DOCS_STAGING_DEPLOY_KEY }} - run: | - if [[ -z "$DEPLOY_KEY" ]]; then - echo "::error::DOCS_STAGING_DEPLOY_KEY is required to deploy staging docs" - exit 1 - fi - - - name: Publish to staging repository - uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 - with: - deploy_key: ${{ secrets.DOCS_STAGING_DEPLOY_KEY }} - external_repository: ${{ vars.DOCS_STAGING_REPOSITORY }} - publish_branch: gh-pages - publish_dir: docs/public - # Each deploy replaces CNAME. Leave the variable unset for github.io URLs. - cname: ${{ vars.DOCS_STAGING_CNAME }} diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index d0935b2600..12889c1a4b 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -3,143 +3,40 @@ name: unbounded docs -# --------------------------------------------------------------------------- -# Publishes the production docs site from the latest published stable release -# only. Pushes to main never deploy: the site must describe what users can -# install, not what has merged since. -# -# Three ways in: -# -# release: published a release published by a human. GitHub suppresses -# this event for releases published with GITHUB_TOKEN, -# so the automated publish path cannot rely on it. -# workflow_dispatch release-upgrade.yaml after it publishes a Latest -# release, or a manual retry for a given tag. Runs -# from main only. -# -# Whatever the trigger, `resolve` checks the tag against the repository's -# latest published release before anything is checked out. Drafts and -# prereleases are never returned by releases/latest, so they cannot pass. -# -# Only `deploy` holds the `pages` concurrency group, so an ineligible run -# exits in `resolve` without replacing a queued valid run. A newer release -# can still finish first, so `deploy` checks the tag again once it holds the -# group and skips publishing if a newer release has become the latest. -# --------------------------------------------------------------------------- - +# Dispatched by release-upgrade.yaml after publishing a latest release. on: - release: - types: [published] workflow_dispatch: - inputs: - tag: - description: "Release tag to publish docs from; must be the latest published stable release" - required: true - type: string + permissions: contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false jobs: - resolve: + build: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - permissions: - contents: read - outputs: - tag: ${{ steps.validate.outputs.tag }} - sha: ${{ steps.validate.outputs.sha }} steps: - # Inputs reach the shell only through env vars, never through expression - # interpolation into the script, so a crafted tag cannot inject commands. - # - # Only the release event is treated specially: every other event, - # including release-upgrade's dispatch, must supply a tag and run on - # main. - - name: Validate tag - id: validate + # Resolve at run time so queued releases and manual retries use the + # latest published release, never unreleased docs from main. + - name: Resolve latest release + id: release env: GH_TOKEN: ${{ github.token }} - EVENT_NAME: ${{ github.event_name }} - EVENT_TAG: ${{ github.event.release.tag_name }} - INPUT_TAG: ${{ inputs.tag }} - RUN_REF: ${{ github.ref }} - run: | - set -euo pipefail - - # Ineligible human-published releases (prereleases, old-series - # patches) are skipped quietly; an explicit request that cannot be - # honored fails, so a broken retry or automation is visible. - # API failures and invalid responses fail for every event. - reject() { - if [ "$EVENT_NAME" = "release" ]; then - echo "::notice::Skipping docs: $1" - exit 0 - fi - echo "::error::$1" - exit 1 - } - - if [ "$EVENT_NAME" = "release" ]; then - TAG="$EVENT_TAG" - else - TAG="$INPUT_TAG" - if [ "$RUN_REF" != "refs/heads/main" ]; then - echo "::error::docs must be requested from refs/heads/main, not ${RUN_REF}" - exit 1 - fi - fi - - # Stable semver only: no prerelease suffix, no shell metacharacters. - if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - reject "'${TAG}' is not a stable release tag" - fi - - # releases/latest returns only published, non-prerelease releases. - LATEST_TAG="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)" \ - || { echo "::error::could not look up the latest release"; exit 1; } - if [ "$TAG" != "$LATEST_TAG" ]; then - reject "${TAG} is not the latest published release (${LATEST_TAG})" - fi - - # Pin the commit now so build cannot pick up a tag moved after - # validation. - SHA="$(gh api "repos/${GITHUB_REPOSITORY}/commits/refs/tags/${TAG}" --jq .sha)" \ - || { echo "::error::could not resolve ${TAG} to a commit"; exit 1; } - if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::${TAG} resolved to an invalid commit '${SHA}'" - exit 1 - fi - - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "sha=${SHA}" >> "$GITHUB_OUTPUT" - echo "Publishing docs from ${TAG} (${SHA})." + run: gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '"tag=" + .tag_name' >> "$GITHUB_OUTPUT" - build: - needs: resolve - if: needs.resolve.outputs.tag != '' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/tags/${{ needs.resolve.outputs.tag }} - persist-credentials: false + ref: refs/tags/${{ steps.release.outputs.tag }} - - name: Verify checkout matches the validated commit - env: - EXPECTED_SHA: ${{ needs.resolve.outputs.sha }} + - name: Install Hugo run: | - set -euo pipefail - ACTUAL_SHA="$(git rev-parse HEAD)" - if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then - echo "::error::checked out ${ACTUAL_SHA}, validated ${EXPECTED_SHA}" - exit 1 - fi - - - uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1 - with: - hugo-version: "latest" - extended: true + sudo apt-get update + sudo apt-get install -y hugo - name: Build run: hugo --minify @@ -154,47 +51,11 @@ jobs: path: docs/public deploy: - needs: [resolve, build] - if: needs.resolve.outputs.tag != '' + needs: build runs-on: ubuntu-latest - # Never cancel: a stale run must not abort a deployment in progress. - # queue: max keeps every pending run instead of letting a later one - # replace it, so a slow older build cannot displace a newer release. - concurrency: - group: pages - cancel-in-progress: false - queue: max - permissions: - contents: read - pages: write - id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - # Queued and approval-gated runs can start after a newer release has - # been published. Skip if the validated tag is no longer the latest. - - name: Recheck latest release - id: recheck - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.resolve.outputs.tag }} - run: | - set -euo pipefail - - LATEST_TAG="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)" \ - || { echo "::error::could not look up the latest release"; exit 1; } - if [ -z "$LATEST_TAG" ]; then - echo "::error::latest release lookup returned no tag" - exit 1 - fi - if [ "$TAG" != "$LATEST_TAG" ]; then - echo "::notice::Skipping deploy: ${TAG} is no longer the latest published release (${LATEST_TAG})" - echo "current=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "current=true" >> "$GITHUB_OUTPUT" - - id: deployment - if: steps.recheck.outputs.current == 'true' uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release-upgrade.yaml b/.github/workflows/release-upgrade.yaml index b83b50df17..9f554710e8 100644 --- a/.github/workflows/release-upgrade.yaml +++ b/.github/workflows/release-upgrade.yaml @@ -1148,22 +1148,8 @@ jobs: echo "- Artifact signatures and BOM: verified" } >> "$GITHUB_STEP_SUMMARY" - # --------------------------------------------------------------------------- - # Publish the production docs site from the release that just became Latest. - # - # Releases published with GITHUB_TOKEN do not emit the `release` event, so - # docs.yaml's own trigger never fires for this path. Dispatching docs.yaml - # instead is exempt from that rule: GITHUB_TOKEN may start workflow_dispatch - # runs. It is a dispatch rather than a reusable call so the Pages deploy, - # and any approval wait on the github-pages environment, runs outside this - # workflow's deploy-unbounded-stable concurrency group and never blocks the - # next cluster upgrade. `--ref main` runs main's docs.yaml, not the copy - # stored in the tag. - # - # Either publish path counts; they are mutually exclusive. Non-Latest - # releases (prereleases, older-series patches) never replace the site. - # docs.yaml re-validates the tag against releases/latest regardless. - # --------------------------------------------------------------------------- + # GITHUB_TOKEN suppresses release events, but permits workflow dispatches. + # Dispatch separately so Pages approval does not hold the cluster lock. docs: needs: [resolve, publish, publish-forced] if: > @@ -1179,9 +1165,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - TAG: ${{ needs.resolve.outputs.tag }} - run: gh workflow run docs.yaml --ref main -f tag="$TAG" - + run: gh workflow run docs.yaml --ref main # --------------------------------------------------------------------------- # CUSTOMIZE: Teams notification (NOT YET ENABLED) diff --git a/.golangci.yaml b/.golangci.yaml index 38b4e3c573..2dfcfb1ecf 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -143,12 +143,6 @@ linters: linters: - misspell text: '`cancelled` is a misspelling' - # GitHub Actions spells its status function `cancelled()`. Preserve the - # literal when testing workflow conditions. - - path: hack/release/docs_workflow_test\.go - linters: - - misspell - text: '`cancelled` is a misspelling' paths: - examples$ - hack/scratch diff --git a/docs/content/guides/gantry.md b/docs/content/guides/gantry.md index 342d09aa09..ed563f286d 100644 --- a/docs/content/guides/gantry.md +++ b/docs/content/guides/gantry.md @@ -35,7 +35,7 @@ set used 99.2% less ACR Private Endpoint traffic than the direct-origin reference set. Mean pod-start P95 was 24.5% lower. Every Gantry run completed all 1,000 pods. -See [Gantry Performance at Scale]({{< relref "gantry-benchmark" >}}) for raw run values, +See [Gantry Performance at Scale](/guides/gantry-benchmark/) for raw run values, methodology, calculations, and limitations. ## Design principles diff --git a/docs/hugo.toml b/docs/hugo.toml index 39d2afed21..03714e7259 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -15,7 +15,6 @@ title = "Unbounded" ordered = false [params] - noindex = false description = "Run nodes across multiple cloud providers and bare-metal infrastructure to form a unified Kubernetes cluster, no matter where your infrastructure lives." repo = "https://github.com/Azure/unbounded" diff --git a/docs/layouts/partials/head.html b/docs/layouts/partials/head.html index b3680f0a64..9a70eb5dd3 100644 --- a/docs/layouts/partials/head.html +++ b/docs/layouts/partials/head.html @@ -2,7 +2,6 @@ {{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }} -{{ if .Site.Params.noindex }}{{ end }} diff --git a/hack/release/docs_staging_workflow_test.go b/hack/release/docs_staging_workflow_test.go deleted file mode 100644 index abffb40aae..0000000000 --- a/hack/release/docs_staging_workflow_test.go +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// SPDX-License-Identifier: Apache-2.0 - -package release - -import ( - "context" - "os" - "os/exec" - "strings" - "testing" - "time" - - "gopkg.in/yaml.v3" -) - -type docsStagingStep struct { - Name string `yaml:"name"` - Uses string `yaml:"uses"` - Run string `yaml:"run"` - Env map[string]string `yaml:"env"` - With map[string]string `yaml:"with"` -} - -type docsStagingWorkflow struct { - On struct { - Push struct { - Branches []string `yaml:"branches"` - } `yaml:"push"` - } `yaml:"on"` - Permissions map[string]string `yaml:"permissions"` - Jobs map[string]struct { - If string `yaml:"if"` - Permissions map[string]string `yaml:"permissions"` - Env map[string]string `yaml:"env"` - Steps []docsStagingStep `yaml:"steps"` - } `yaml:"jobs"` -} - -func loadDocsStagingWorkflow(t *testing.T) docsStagingWorkflow { - t.Helper() - - data, err := os.ReadFile("../../.github/workflows/docs-staging.yaml") - if err != nil { - t.Fatal(err) - } - - var wf docsStagingWorkflow - if err := yaml.Unmarshal(data, &wf); err != nil { - t.Fatal(err) - } - - if _, ok := wf.Jobs["deploy"]; !ok { - t.Fatal("docs-staging workflow has no deploy job") - } - - return wf -} - -func docsStagingStepNamed(t *testing.T, wf docsStagingWorkflow, name string) docsStagingStep { - t.Helper() - - for _, s := range wf.Jobs["deploy"].Steps { - if s.Name == name { - return s - } - } - - t.Fatalf("deploy job has no step %q", name) - - return docsStagingStep{} -} - -func TestDocsStagingIsMainOnlyAndOptional(t *testing.T) { - t.Parallel() - - wf := loadDocsStagingWorkflow(t) - - if got := strings.Join(wf.On.Push.Branches, ","); got != "main" { - t.Fatalf("push branches = %q, want only main", got) - } - - cond := wf.Jobs["deploy"].If - for _, want := range []string{"github.ref == 'refs/heads/main'", "vars.DOCS_STAGING_REPOSITORY != ''"} { - if !strings.Contains(cond, want) { - t.Fatalf("deploy condition missing %q:\n%s", want, cond) - } - } -} - -func TestDocsStagingCannotPublishToProductionPages(t *testing.T) { - t.Parallel() - - wf := loadDocsStagingWorkflow(t) - job := wf.Jobs["deploy"] - - for _, perms := range []map[string]string{wf.Permissions, job.Permissions} { - if perms["pages"] != "" || perms["id-token"] != "" { - t.Fatalf("staging workflow must not request pages/id-token permissions: %v", perms) - } - - if p := perms["contents"]; p != "" && p != "read" { - t.Fatalf("contents permission = %q, want read", p) - } - } - - var publish *docsStagingStep - - for i, s := range job.Steps { - if strings.Contains(s.Uses, "deploy-pages") || strings.Contains(s.Uses, "upload-pages-artifact") { - t.Fatalf("staging workflow uses production Pages action %q", s.Uses) - } - - if strings.HasPrefix(s.Uses, "peaceiris/actions-gh-pages@") { - publish = &job.Steps[i] - } - } - - if publish == nil { - t.Fatal("no actions-gh-pages publish step") - } - - if publish.With["external_repository"] != "${{ vars.DOCS_STAGING_REPOSITORY }}" { - t.Fatalf("publish must target the staging repository, got %q", publish.With["external_repository"]) - } - - if publish.With["deploy_key"] != "${{ secrets.DOCS_STAGING_DEPLOY_KEY }}" { - t.Fatalf("publish must use the staging deploy key, got %q", publish.With["deploy_key"]) - } - - // An unset variable expands to an empty input, so no CNAME is written. - if publish.With["cname"] != "${{ vars.DOCS_STAGING_CNAME }}" { - t.Fatalf("publish cname = %q, want the DOCS_STAGING_CNAME variable", publish.With["cname"]) - } - - if publish.With["github_token"] != "" || publish.With["personal_token"] != "" { - t.Fatal("publish must not use the workflow token or a personal token") - } -} - -func TestDocsStagingBuildsWithProjectBaseURLAndNoindex(t *testing.T) { - t.Parallel() - - wf := loadDocsStagingWorkflow(t) - - if got := wf.Jobs["deploy"].Env["STAGING_BASE_URL"]; got != "${{ vars.DOCS_STAGING_BASE_URL }}" { - t.Fatalf("STAGING_BASE_URL = %q", got) - } - - build := docsStagingStepNamed(t, wf, "Build staging docs") - if !strings.Contains(build.Run, `--baseURL "$STAGING_BASE_URL"`) { - t.Fatalf("build does not use the staging base URL:\n%s", build.Run) - } - - if build.Env["HUGO_PARAMS_NOINDEX"] != "true" { - t.Fatalf("HUGO_PARAMS_NOINDEX = %q, want true", build.Env["HUGO_PARAMS_NOINDEX"]) - } -} - -// runDocsStagingStep runs a step's bash script with only the given env, so no -// real secret or remote is ever reachable. -func runDocsStagingStep(t *testing.T, script string, env map[string]string) (string, int) { - t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "bash", "-c", script) - cmd.Env = []string{"PATH=" + os.Getenv("PATH")} - - for k, v := range env { - cmd.Env = append(cmd.Env, k+"="+v) - } - - out, err := cmd.CombinedOutput() - if err == nil { - return string(out), 0 - } - - if exit, ok := err.(*exec.ExitError); ok && ctx.Err() == nil { - return string(out), exit.ExitCode() - } - - t.Fatalf("running step: %v\n%s", err, out) - - return "", -1 -} - -func TestDocsStagingValidatesSettings(t *testing.T) { - t.Parallel() - requireBash4(t) - - script := docsStagingStepNamed(t, loadDocsStagingWorkflow(t), "Validate staging settings").Run - - const ( - repo = "example/unbounded-staging" - url = "https://example.github.io/unbounded-staging/" - ) - - for _, tc := range []struct { - name, repo, url, wantErr string - }{ - {name: "project URL", repo: repo, url: url}, - {name: "root URL", repo: repo, url: "https://staging.example.com/"}, - {name: "no repository", repo: "", url: url, wantErr: "DOCS_STAGING_REPOSITORY"}, - {name: "same repository", repo: "Microsoft/Unbounded", url: url, wantErr: "DOCS_STAGING_REPOSITORY"}, - {name: "malformed repository", repo: "example/x/y", url: url, wantErr: "DOCS_STAGING_REPOSITORY"}, - {name: "no URL", repo: repo, url: "", wantErr: "DOCS_STAGING_BASE_URL"}, - {name: "HTTP URL", repo: repo, url: "http://example.github.io/unbounded-staging/", wantErr: "DOCS_STAGING_BASE_URL"}, - {name: "missing slash", repo: repo, url: "https://example.github.io/unbounded-staging", wantErr: "DOCS_STAGING_BASE_URL"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - out, code := runDocsStagingStep(t, script, map[string]string{ - "GITHUB_REPOSITORY": "microsoft/unbounded", - "STAGING_REPOSITORY": tc.repo, - "STAGING_BASE_URL": tc.url, - }) - if tc.wantErr == "" { - if code != 0 { - t.Fatalf("exit %d, want 0:\n%s", code, out) - } - - return - } - - if code == 0 || !strings.Contains(out, "::error::"+tc.wantErr) { - t.Fatalf("exit %d, want error about %s:\n%s", code, tc.wantErr, out) - } - }) - } -} - -func TestDocsStagingRequiresDeployKey(t *testing.T) { - t.Parallel() - requireBash4(t) - - script := docsStagingStepNamed(t, loadDocsStagingWorkflow(t), "Require staging deploy key").Run - - out, code := runDocsStagingStep(t, script, map[string]string{"DEPLOY_KEY": "placeholder-not-a-key"}) - if code != 0 || strings.Contains(out, "placeholder") { - t.Fatalf("present key: exit %d, output %q", code, out) - } - - out, code = runDocsStagingStep(t, script, nil) - if code == 0 || !strings.Contains(out, "::error::DOCS_STAGING_DEPLOY_KEY") { - t.Fatalf("absent key: exit %d:\n%s", code, out) - } -} diff --git a/hack/release/docs_workflow_test.go b/hack/release/docs_workflow_test.go deleted file mode 100644 index 7e10d9e0bc..0000000000 --- a/hack/release/docs_workflow_test.go +++ /dev/null @@ -1,548 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// SPDX-License-Identifier: Apache-2.0 - -package release - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "gopkg.in/yaml.v3" -) - -type docsStep struct { - ID string `yaml:"id"` - If string `yaml:"if"` - Name string `yaml:"name"` - Uses string `yaml:"uses"` - Run string `yaml:"run"` - With map[string]string `yaml:"with"` - Env map[string]string `yaml:"env"` -} - -type docsConcurrency struct { - Group string `yaml:"group"` - CancelInProgress bool `yaml:"cancel-in-progress"` - Queue string `yaml:"queue"` -} - -type docsJob struct { - Needs any `yaml:"needs"` - If string `yaml:"if"` - Concurrency *docsConcurrency `yaml:"concurrency"` - Uses string `yaml:"uses"` - With map[string]string `yaml:"with"` - Permissions map[string]string `yaml:"permissions"` - Steps []docsStep `yaml:"steps"` -} - -type docsWorkflow struct { - On map[string]yaml.Node `yaml:"on"` - Permissions map[string]string `yaml:"permissions"` - Concurrency *docsConcurrency `yaml:"concurrency"` - Jobs map[string]docsJob `yaml:"jobs"` -} - -func loadWorkflow(t *testing.T, name string) docsWorkflow { - t.Helper() - - data, err := os.ReadFile(filepath.Join("../../.github/workflows", name)) - if err != nil { - t.Fatal(err) - } - - var wf docsWorkflow - if err := yaml.Unmarshal(data, &wf); err != nil { - t.Fatal(err) - } - - return wf -} - -func TestDocsWorkflowTriggers(t *testing.T) { - t.Parallel() - - wf := loadWorkflow(t, "docs.yaml") - - if _, ok := wf.On["push"]; ok { - t.Fatal("docs must not deploy on push") - } - - var release struct { - Types []string `yaml:"types"` - } - if n, ok := wf.On["release"]; !ok { - t.Fatal("docs must trigger on release") - } else if err := n.Decode(&release); err != nil { - t.Fatal(err) - } - - if strings.Join(release.Types, ",") != "published" { - t.Fatalf("release types = %v, want [published]", release.Types) - } - - if _, ok := wf.On["workflow_call"]; ok { - t.Fatal("docs must not be a reusable workflow; release-upgrade dispatches it") - } - - for _, event := range []string{"workflow_dispatch"} { - var trig struct { - Inputs map[string]struct { - Required bool `yaml:"required"` - Type string `yaml:"type"` - } `yaml:"inputs"` - } - - n, ok := wf.On[event] - if !ok { - t.Fatalf("docs must trigger on %s", event) - } - - if err := n.Decode(&trig); err != nil { - t.Fatal(err) - } - - tag := trig.Inputs["tag"] - if !tag.Required || tag.Type != "string" { - t.Fatalf("%s tag input = %+v, want required string", event, tag) - } - } - - // Ineligible runs must exit before entering the pages group, so only - // deploy holds it, and it never cancels a running deployment. - if wf.Concurrency != nil { - t.Fatalf("workflow-level concurrency = %+v, want none", wf.Concurrency) - } - - for name, job := range wf.Jobs { - c := job.Concurrency - if name == "deploy" { - // actionlint cannot parse queue, so this is the only typo guard. - if c == nil || c.Group != "pages" || c.CancelInProgress || c.Queue != "max" { - t.Fatalf("deploy concurrency = %+v, want pages, no cancel, queue max", c) - } - } else if c != nil { - t.Fatalf("%s concurrency = %+v, want none", name, c) - } - } -} - -func TestDocsWorkflowPermissionsAndOrdering(t *testing.T) { - t.Parallel() - - wf := loadWorkflow(t, "docs.yaml") - - if len(wf.Permissions) != 1 || wf.Permissions["contents"] != "read" { - t.Fatalf("workflow permissions = %v, want contents: read only", wf.Permissions) - } - - for _, name := range []string{"resolve", "build"} { - p := wf.Jobs[name].Permissions - if len(p) != 1 || p["contents"] != "read" { - t.Fatalf("%s permissions = %v, want contents: read only", name, p) - } - } - - deploy := wf.Jobs["deploy"] - if len(deploy.Permissions) != 3 || deploy.Permissions["pages"] != "write" || - deploy.Permissions["id-token"] != "write" || deploy.Permissions["contents"] != "read" { - t.Fatalf("deploy permissions = %v", deploy.Permissions) - } - - needs, _ := deploy.Needs.([]any) - if len(needs) != 2 || needs[0] != "resolve" || needs[1] != "build" || - deploy.If != "needs.resolve.outputs.tag != ''" { - t.Fatalf("deploy must wait for resolve and build: needs=%v if=%q", deploy.Needs, deploy.If) - } - - if len(deploy.Steps) != 2 || deploy.Steps[0].ID != "recheck" || - deploy.Steps[0].Env["TAG"] != "${{ needs.resolve.outputs.tag }}" || - strings.Contains(deploy.Steps[0].Run, "${{") { - t.Fatalf("deploy must recheck the validated tag via env first: %+v", deploy.Steps) - } - - if !strings.HasPrefix(deploy.Steps[1].Uses, "actions/deploy-pages@") || - deploy.Steps[1].If != "steps.recheck.outputs.current == 'true'" { - t.Fatalf("deploy-pages must run only when the tag is still current: %+v", deploy.Steps[1]) - } - - build := wf.Jobs["build"] - if build.Needs != "resolve" || build.If != "needs.resolve.outputs.tag != ''" { - t.Fatalf("build must wait for a validated tag: needs=%v if=%q", build.Needs, build.If) - } - - checkout := build.Steps[0] - if !strings.HasPrefix(checkout.Uses, "actions/checkout@") || - checkout.With["ref"] != "refs/tags/${{ needs.resolve.outputs.tag }}" { - t.Fatalf("build first step must check out the validated tag: %+v", checkout) - } - - if !strings.Contains(build.Steps[1].Run, `"$ACTUAL_SHA" != "$EXPECTED_SHA"`) || - build.Steps[1].Env["EXPECTED_SHA"] != "${{ needs.resolve.outputs.sha }}" { - t.Fatalf("build must verify the validated commit: %+v", build.Steps[1]) - } - - for _, step := range wf.Jobs["resolve"].Steps { - if strings.HasPrefix(step.Uses, "actions/checkout@") { - t.Fatal("resolve must not check anything out before validation") - } - - if strings.Contains(step.Run, "${{") { - t.Fatalf("resolve script interpolates expressions; use env vars:\n%s", step.Run) - } - } -} - -func TestReleaseUpgradeDispatchesDocs(t *testing.T) { - t.Parallel() - - wf := loadWorkflow(t, "release-upgrade.yaml") - - docs, ok := wf.Jobs["docs"] - if !ok { - t.Fatal("release-upgrade must dispatch docs") - } - - // A reusable call would hold deploy-unbounded-stable while the - // github-pages environment waits for approval. - if docs.Uses != "" { - t.Fatalf("docs must be dispatched, not called: uses %q", docs.Uses) - } - - needs, _ := docs.Needs.([]any) - - got := []string{} - for _, n := range needs { - got = append(got, n.(string)) - } - - if strings.Join(got, ",") != "resolve,publish,publish-forced" { - t.Fatalf("docs needs = %v", got) - } - - for _, want := range []string{ - "!cancelled()", - "needs.resolve.result == 'success'", - "needs.resolve.outputs.latest == 'true'", - "(needs.publish.result == 'success' || needs.publish-forced.result == 'success')", - } { - if !strings.Contains(docs.If, want) { - t.Fatalf("docs condition missing %q:\n%s", want, docs.If) - } - } - - if len(docs.Permissions) != 1 || docs.Permissions["actions"] != "write" { - t.Fatalf("docs permissions = %v, want only actions: write", docs.Permissions) - } - - if len(docs.Steps) != 1 { - t.Fatalf("docs steps = %d, want 1", len(docs.Steps)) - } - - step := docs.Steps[0] - if step.Uses != "" { - t.Fatalf("dispatch step uses %q", step.Uses) - } - - if step.Env["TAG"] != "${{ needs.resolve.outputs.tag }}" || - step.Env["GH_TOKEN"] != "${{ github.token }}" || - step.Env["GH_REPO"] != "${{ github.repository }}" { - t.Fatalf("dispatch env = %v", step.Env) - } - - if strings.Contains(step.Run, "${{") { - t.Fatalf("dispatch script interpolates expressions; use env vars:\n%s", step.Run) - } - - runDispatch(t, step.Run) -} - -// runDispatch runs the dispatch script against a fake gh and checks that it -// targets main's docs.yaml with the resolved tag, passed as one argument. -func runDispatch(t *testing.T, script string) { - t.Helper() - - if err := os.MkdirAll("../../tmp", 0o755); err != nil { - t.Fatal(err) - } - - dir, err := os.MkdirTemp("../../tmp", "docs-dispatch-") - if err != nil { - t.Fatal(err) - } - - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - - dir, err = filepath.Abs(dir) - if err != nil { - t.Fatal(err) - } - - gh := "#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > \"$GH_LOG\"\n" - if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(gh), 0o755); err != nil { - t.Fatal(err) - } - - log := filepath.Join(dir, "gh.log") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "bash", "-euo", "pipefail", "-c", script) - cmd.Env = []string{ - "PATH=" + dir + ":" + os.Getenv("PATH"), - "GH_LOG=" + log, - "GH_TOKEN=token", - "GH_REPO=Azure/unbounded", - "TAG=v1.4.0 extra", - } - - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("dispatch failed: %v\n%s", err, out) - } - - data, err := os.ReadFile(log) - if err != nil { - t.Fatal(err) - } - - want := "workflow\nrun\ndocs.yaml\n--ref\nmain\n-f\ntag=v1.4.0 extra\n" - if string(data) != want { - t.Fatalf("gh args = %q, want %q", data, want) - } -} - -const ( - latestTag = "v1.4.0" - tagSHA = "0123456789abcdef0123456789abcdef01234567" -) - -// fakeGH mimics `gh api ... --jq .` for releases/latest and tag commits. -// releases/latest never returns drafts or prereleases, so those tags simply -// differ from latestTag. -const fakeGH = `#!/usr/bin/env bash -set -euo pipefail -echo "$*" >> "$GH_LOG" -case "$2" in - */releases/latest) [ -n "${FAKE_LATEST:-}" ] || exit 1; echo "$FAKE_LATEST" ;; - */commits/refs/tags/*) [ -n "${FAKE_SHA:-}" ] || exit 1; echo "$FAKE_SHA" ;; - *) exit 1 ;; -esac -` - -func TestDocsResolveScript(t *testing.T) { - t.Parallel() - - var script string - - for _, step := range loadWorkflow(t, "docs.yaml").Jobs["resolve"].Steps { - if step.ID == "validate" { - script = step.Run - } - } - - if script == "" { - t.Fatal("validate step not found") - } - - cases := []struct { - name string - event string - tag string - ref string - latest string - sha string - wantErr bool - wantTag string - }{ - {name: "published latest", event: "release", tag: latestTag, wantTag: latestTag}, - {name: "release prerelease skipped", event: "release", tag: "v1.5.0-rc.1"}, - {name: "release old stable skipped", event: "release", tag: "v1.3.9"}, - {name: "dispatch latest", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", wantTag: latestTag}, - {name: "call from workflow_run", event: "workflow_run", tag: latestTag, ref: "refs/heads/main", wantTag: latestTag}, - {name: "dispatch off main", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/feature", wantErr: true}, - {name: "dispatch old stable", event: "workflow_dispatch", tag: "v1.3.9", ref: "refs/heads/main", wantErr: true}, - {name: "dispatch draft or unknown", event: "workflow_dispatch", tag: "v9.9.9", ref: "refs/heads/main", wantErr: true}, - {name: "dispatch prerelease", event: "workflow_dispatch", tag: "v1.4.0-rc.1", ref: "refs/heads/main", wantErr: true}, - {name: "dispatch empty", event: "workflow_dispatch", tag: "", ref: "refs/heads/main", wantErr: true}, - {name: "injection", event: "workflow_dispatch", tag: "v1.4.0\";touch pwned;\"", ref: "refs/heads/main", wantErr: true}, - {name: "newline injection", event: "workflow_dispatch", tag: "v1.4.0\nsha=evil", ref: "refs/heads/main", wantErr: true}, - {name: "no latest release", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", latest: "-", wantErr: true}, - {name: "bad sha", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", sha: "nope", wantErr: true}, - {name: "multiline sha", event: "workflow_dispatch", tag: latestTag, ref: "refs/heads/main", sha: tagSHA + "\ntag=evil", wantErr: true}, - {name: "release latest lookup fails", event: "release", tag: latestTag, latest: "-", wantErr: true}, - {name: "release commit lookup fails", event: "release", tag: latestTag, sha: "-", wantErr: true}, - {name: "release bad sha", event: "release", tag: latestTag, sha: "nope", wantErr: true}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if err := os.MkdirAll("../../tmp", 0o755); err != nil { - t.Fatal(err) - } - - dir, err := os.MkdirTemp("../../tmp", "docs-resolve-") - if err != nil { - t.Fatal(err) - } - - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - - dir, err = filepath.Abs(dir) - if err != nil { - t.Fatal(err) - } - - if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fakeGH), 0o755); err != nil { - t.Fatal(err) - } - - latest := tc.latest - switch latest { - case "": - latest = latestTag - case "-": - latest = "" - } - - sha := tc.sha - switch sha { - case "": - sha = tagSHA - case "-": - sha = "" - } - - output := filepath.Join(dir, "output") - - inputTag, eventTag := tc.tag, "" - if tc.event == "release" { - inputTag, eventTag = "", tc.tag - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "bash", "-c", script) - cmd.Dir = dir - cmd.Env = []string{ - "PATH=" + dir + ":" + os.Getenv("PATH"), - "GITHUB_OUTPUT=" + output, - "GITHUB_REPOSITORY=Azure/unbounded", - "GH_LOG=" + filepath.Join(dir, "gh.log"), - "EVENT_NAME=" + tc.event, - "EVENT_TAG=" + eventTag, - "INPUT_TAG=" + inputTag, - "RUN_REF=" + tc.ref, - "FAKE_LATEST=" + latest, - "FAKE_SHA=" + sha, - } - - out, err := cmd.CombinedOutput() - if tc.wantErr != (err != nil) { - t.Fatalf("err = %v, wantErr %v\n%s", err, tc.wantErr, out) - } - - if _, err := os.Stat(filepath.Join(dir, "pwned")); err == nil { - t.Fatal("tag was executed as shell") - } - - got, _ := os.ReadFile(output) - - want := "" - if tc.wantTag != "" { - want = "tag=" + tc.wantTag + "\nsha=" + tagSHA + "\n" - } - - if string(got) != want { - t.Fatalf("outputs = %q, want %q\n%s", got, want, out) - } - }) - } -} - -func TestDocsDeployRecheckScript(t *testing.T) { - t.Parallel() - - var script string - - for _, step := range loadWorkflow(t, "docs.yaml").Jobs["deploy"].Steps { - if step.ID == "recheck" { - script = step.Run - } - } - - if script == "" { - t.Fatal("recheck step not found") - } - - cases := []struct { - name string - latest string - wantErr bool - want string - }{ - {name: "still latest", latest: latestTag, want: "current=true\n"}, - {name: "newer release published", latest: "v1.5.0", want: "current=false\n"}, - {name: "lookup fails", latest: "", wantErr: true}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if err := os.MkdirAll("../../tmp", 0o755); err != nil { - t.Fatal(err) - } - - dir, err := os.MkdirTemp("../../tmp", "docs-recheck-") - if err != nil { - t.Fatal(err) - } - - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - - dir, err = filepath.Abs(dir) - if err != nil { - t.Fatal(err) - } - - if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fakeGH), 0o755); err != nil { - t.Fatal(err) - } - - output := filepath.Join(dir, "output") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "bash", "-c", script) - cmd.Dir = dir - cmd.Env = []string{ - "PATH=" + dir + ":" + os.Getenv("PATH"), - "GITHUB_OUTPUT=" + output, - "GITHUB_REPOSITORY=Azure/unbounded", - "GH_LOG=" + filepath.Join(dir, "gh.log"), - "TAG=" + latestTag, - "FAKE_LATEST=" + tc.latest, - } - - out, err := cmd.CombinedOutput() - if tc.wantErr != (err != nil) { - t.Fatalf("err = %v, wantErr %v\n%s", err, tc.wantErr, out) - } - - got, _ := os.ReadFile(output) - if string(got) != tc.want { - t.Fatalf("outputs = %q, want %q\n%s", got, tc.want, out) - } - }) - } -} diff --git a/hack/release/release_upgrade_workflow_test.go b/hack/release/release_upgrade_workflow_test.go index eb4974c02b..fa99727230 100644 --- a/hack/release/release_upgrade_workflow_test.go +++ b/hack/release/release_upgrade_workflow_test.go @@ -4,6 +4,7 @@ package release import ( + "fmt" "os" "strings" "testing" @@ -33,3 +34,108 @@ func TestPublishRequiresResolvedRelease(t *testing.T) { t.Fatalf("publish condition can run without a resolved release:\n%s", condition) } } + +type workflowStep struct { + ID string `yaml:"id"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + With map[string]string `yaml:"with"` + Env map[string]string `yaml:"env"` +} + +type workflowJob struct { + If string `yaml:"if"` + Needs any `yaml:"needs"` + Permissions map[string]string `yaml:"permissions"` + Steps []workflowStep `yaml:"steps"` +} + +func readWorkflow(t *testing.T, name string) (on map[string]any, jobs map[string]workflowJob) { + t.Helper() + + data, err := os.ReadFile("../../.github/workflows/" + name) + if err != nil { + t.Fatal(err) + } + + var wf struct { + On map[string]any `yaml:"on"` + Jobs map[string]workflowJob `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &wf); err != nil { + t.Fatal(err) + } + + return wf.On, wf.Jobs +} + +func TestDocsDeploysLatestReleaseOnDispatchOnly(t *testing.T) { + t.Parallel() + + on, jobs := readWorkflow(t, "docs.yaml") + if inputs, ok := on["workflow_dispatch"]; len(on) != 1 || !ok || inputs != nil { + t.Fatalf("docs must trigger only on input-less workflow_dispatch: %v", on) + } + + build := jobs["build"] + if build.If != "github.ref == 'refs/heads/main'" { + t.Fatalf("build must be guarded to main: %q", build.If) + } + + if len(build.Steps) == 0 || build.Steps[0].ID != "release" || + !strings.Contains(build.Steps[0].Run, `releases/latest`) || build.Steps[0].Env["GH_TOKEN"] == "" { + t.Fatalf("first build step must resolve the latest release: %+v", build.Steps) + } + + checkouts := 0 + + for name, job := range jobs { + for _, s := range job.Steps { + if s.Uses != "" && !strings.HasPrefix(s.Uses, "actions/") { + t.Errorf("job %s uses non-official action %q", name, s.Uses) + } + + if strings.HasPrefix(s.Uses, "actions/checkout@") { + checkouts++ + + if s.With["ref"] != "refs/tags/${{ steps.release.outputs.tag }}" { + t.Errorf("checkout must use the latest release tag, got %q", s.With["ref"]) + } + } + } + } + + if checkouts != 1 { + t.Fatalf("expected exactly one checkout, got %d", checkouts) + } +} + +func TestReleaseUpgradeDispatchesLatestDocs(t *testing.T) { + t.Parallel() + + _, jobs := readWorkflow(t, "release-upgrade.yaml") + docs := jobs["docs"] + + if fmt.Sprint(docs.Needs) != "[resolve publish publish-forced]" { + t.Fatalf("docs needs = %v", docs.Needs) + } + + for _, want := range []string{ + "needs.resolve.result == 'success'", + "needs.resolve.outputs.latest == 'true'", + "needs.publish.result == 'success' || needs.publish-forced.result == 'success'", + } { + if !strings.Contains(docs.If, want) { + t.Errorf("docs condition missing %q:\n%s", want, docs.If) + } + } + + if len(docs.Permissions) != 1 || docs.Permissions["actions"] != "write" { + t.Errorf("docs permissions = %v, want only actions: write", docs.Permissions) + } + + if len(docs.Steps) != 1 || docs.Steps[0].Uses != "" || + strings.TrimSpace(docs.Steps[0].Run) != "gh workflow run docs.yaml --ref main" { + t.Errorf("docs must only dispatch docs.yaml on main: %+v", docs.Steps) + } +} From 1a4cf937c1e6c186cc69bc28e7cd1c7dbdb5c448 Mon Sep 17 00:00:00 2001 From: Jordan Olshevski Date: Wed, 28 Oct 2026 18:18:30 +0000 Subject: [PATCH 10/10] Deploy Pages directly in the release publishing workflow --- .github/workflows/docs.yaml | 61 --------- .github/workflows/release-upgrade.yaml | 38 ++++-- hack/release/release_upgrade_workflow_test.go | 125 ++++++++---------- 3 files changed, 87 insertions(+), 137 deletions(-) delete mode 100644 .github/workflows/docs.yaml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml deleted file mode 100644 index 12889c1a4b..0000000000 --- a/.github/workflows/docs.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# SPDX-License-Identifier: Apache-2.0 - -name: unbounded docs - -# Dispatched by release-upgrade.yaml after publishing a latest release. -on: - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - # Resolve at run time so queued releases and manual retries use the - # latest published release, never unreleased docs from main. - - name: Resolve latest release - id: release - env: - GH_TOKEN: ${{ github.token }} - run: gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '"tag=" + .tag_name' >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: refs/tags/${{ steps.release.outputs.tag }} - - - name: Install Hugo - run: | - sudo apt-get update - sudo apt-get install -y hugo - - - name: Build - run: hugo --minify - working-directory: docs - - - name: Build search index - run: npx --yes pagefind --site public - working-directory: docs - - - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: docs/public - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release-upgrade.yaml b/.github/workflows/release-upgrade.yaml index 9f554710e8..412318fe2a 100644 --- a/.github/workflows/release-upgrade.yaml +++ b/.github/workflows/release-upgrade.yaml @@ -1148,8 +1148,7 @@ jobs: echo "- Artifact signatures and BOM: verified" } >> "$GITHUB_STEP_SUMMARY" - # GITHUB_TOKEN suppresses release events, but permits workflow dispatches. - # Dispatch separately so Pages approval does not hold the cluster lock. + # Build docs from the release tag after publishing the latest release. docs: needs: [resolve, publish, publish-forced] if: > @@ -1159,13 +1158,36 @@ jobs: (needs.publish.result == 'success' || needs.publish-forced.result == 'success') runs-on: ubuntu-latest permissions: - actions: write + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - - name: Dispatch docs workflow - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: gh workflow run docs.yaml --ref main + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.resolve.outputs.tag }} + + - name: Install Hugo + run: | + sudo apt-get update + sudo apt-get install -y hugo + + - name: Build docs + run: hugo --minify + working-directory: docs + + - name: Build search index + run: npx --yes pagefind --site public + working-directory: docs + + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: docs/public + + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 # --------------------------------------------------------------------------- # CUSTOMIZE: Teams notification (NOT YET ENABLED) diff --git a/hack/release/release_upgrade_workflow_test.go b/hack/release/release_upgrade_workflow_test.go index fa99727230..37d9f9f66f 100644 --- a/hack/release/release_upgrade_workflow_test.go +++ b/hack/release/release_upgrade_workflow_test.go @@ -35,87 +35,39 @@ func TestPublishRequiresResolvedRelease(t *testing.T) { } } -type workflowStep struct { - ID string `yaml:"id"` - Uses string `yaml:"uses"` - Run string `yaml:"run"` - With map[string]string `yaml:"with"` - Env map[string]string `yaml:"env"` -} - type workflowJob struct { If string `yaml:"if"` Needs any `yaml:"needs"` Permissions map[string]string `yaml:"permissions"` - Steps []workflowStep `yaml:"steps"` + Environment any `yaml:"environment"` + Steps []struct { + ID string `yaml:"id"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + With map[string]string `yaml:"with"` + } `yaml:"steps"` } -func readWorkflow(t *testing.T, name string) (on map[string]any, jobs map[string]workflowJob) { - t.Helper() +func TestReleaseUpgradeDeploysLatestDocs(t *testing.T) { + t.Parallel() + + if _, err := os.Stat("../../.github/workflows/docs.yaml"); !os.IsNotExist(err) { + t.Fatalf("standalone docs workflow must not exist: %v", err) + } - data, err := os.ReadFile("../../.github/workflows/" + name) + data, err := os.ReadFile("../../.github/workflows/release-upgrade.yaml") if err != nil { t.Fatal(err) } var wf struct { - On map[string]any `yaml:"on"` Jobs map[string]workflowJob `yaml:"jobs"` } if err := yaml.Unmarshal(data, &wf); err != nil { t.Fatal(err) } - return wf.On, wf.Jobs -} - -func TestDocsDeploysLatestReleaseOnDispatchOnly(t *testing.T) { - t.Parallel() - - on, jobs := readWorkflow(t, "docs.yaml") - if inputs, ok := on["workflow_dispatch"]; len(on) != 1 || !ok || inputs != nil { - t.Fatalf("docs must trigger only on input-less workflow_dispatch: %v", on) - } - - build := jobs["build"] - if build.If != "github.ref == 'refs/heads/main'" { - t.Fatalf("build must be guarded to main: %q", build.If) - } - - if len(build.Steps) == 0 || build.Steps[0].ID != "release" || - !strings.Contains(build.Steps[0].Run, `releases/latest`) || build.Steps[0].Env["GH_TOKEN"] == "" { - t.Fatalf("first build step must resolve the latest release: %+v", build.Steps) - } - - checkouts := 0 - - for name, job := range jobs { - for _, s := range job.Steps { - if s.Uses != "" && !strings.HasPrefix(s.Uses, "actions/") { - t.Errorf("job %s uses non-official action %q", name, s.Uses) - } - - if strings.HasPrefix(s.Uses, "actions/checkout@") { - checkouts++ - - if s.With["ref"] != "refs/tags/${{ steps.release.outputs.tag }}" { - t.Errorf("checkout must use the latest release tag, got %q", s.With["ref"]) - } - } - } - } - - if checkouts != 1 { - t.Fatalf("expected exactly one checkout, got %d", checkouts) - } -} - -func TestReleaseUpgradeDispatchesLatestDocs(t *testing.T) { - t.Parallel() - - _, jobs := readWorkflow(t, "release-upgrade.yaml") - docs := jobs["docs"] - + docs := wf.Jobs["docs"] if fmt.Sprint(docs.Needs) != "[resolve publish publish-forced]" { t.Fatalf("docs needs = %v", docs.Needs) } @@ -130,12 +82,49 @@ func TestReleaseUpgradeDispatchesLatestDocs(t *testing.T) { } } - if len(docs.Permissions) != 1 || docs.Permissions["actions"] != "write" { - t.Errorf("docs permissions = %v, want only actions: write", docs.Permissions) + if fmt.Sprint(docs.Permissions) != "map[contents:read id-token:write pages:write]" { + t.Errorf("docs permissions = %v", docs.Permissions) + } + + if fmt.Sprint(docs.Environment) != "map[name:github-pages url:${{ steps.deployment.outputs.page_url }}]" { + t.Errorf("docs environment = %v", docs.Environment) + } + + var uses []string + + for _, s := range docs.Steps { + if strings.Contains(s.Run, "gh workflow") { + t.Errorf("docs must not dispatch another workflow: %q", s.Run) + } + + if s.Uses == "" { + continue + } + + if !strings.HasPrefix(s.Uses, "actions/") { + t.Errorf("docs uses non-official action %q", s.Uses) + } + + name, _, _ := strings.Cut(s.Uses, "@") + uses = append(uses, name) + + switch name { + case "actions/checkout": + if s.With["ref"] != "refs/tags/${{ needs.resolve.outputs.tag }}" { + t.Errorf("checkout must use the release tag, got %q", s.With["ref"]) + } + case "actions/upload-pages-artifact": + if s.With["path"] != "docs/public" { + t.Errorf("pages artifact path = %q", s.With["path"]) + } + case "actions/deploy-pages": + if s.ID != "deployment" { + t.Errorf("deploy-pages id = %q", s.ID) + } + } } - if len(docs.Steps) != 1 || docs.Steps[0].Uses != "" || - strings.TrimSpace(docs.Steps[0].Run) != "gh workflow run docs.yaml --ref main" { - t.Errorf("docs must only dispatch docs.yaml on main: %+v", docs.Steps) + if fmt.Sprint(uses) != "[actions/checkout actions/upload-pages-artifact actions/deploy-pages]" { + t.Errorf("docs actions = %v, want checkout, upload, deploy in order", uses) } }