test(outlook-addon): pin buildSignAttributes' combined flow #100
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Outlook addon | |
| # Checks and release for apps/outlook-addon. | |
| # | |
| # Deliberately NOT path-filtered: the point of the monorepo is that an SDK change | |
| # in packages/pg-js is tested against its consumers in the same PR. | |
| # | |
| # Image name is hardcoded, not derived from ${{ github.repository }}: that | |
| # expression resolves to `postguard-js` here, while postguard-ops pins | |
| # ghcr.io/encryption4all/postguard-outlook-addon, so deriving it would publish to | |
| # a name nothing deploys. | |
| # | |
| # Release tags are app-scoped (`outlook-addin-v0.5.1`), never `v*`: the tag | |
| # namespace is shared with @e4a/pg-js's changesets releases and still holds the | |
| # pre-monorepo `v2.3.3`-style pg-js tags. | |
| on: | |
| pull_request: | |
| branches: [main] | |
| push: | |
| branches: [main] | |
| tags: ['outlook-addin-v*'] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| # This workflow is the only publisher of the image below. There was briefly a | |
| # race — encryption4all/postguard-outlook-addon's own release.yml pushed `:edge` | |
| # and `:latest` to the same package and the two could overwrite each other with | |
| # different content — and that is over: the repo is archived, and an archived | |
| # repo runs no workflows, so nothing else can push this name. Note that | |
| # archiving the *source* repo of a GHCR package does not revoke the package | |
| # itself or anyone's push access to it, so "the old repo is archived" is the | |
| # reason the race ended, not "the package moved". | |
| env: | |
| IMAGE: ghcr.io/encryption4all/postguard-outlook-addon | |
| # Baked into the bundle by webpack's DefinePlugin. `storage.postguard.eu`, | |
| # not `fileshare.*`: the latter no longer resolves and shipping it broke file | |
| # sending for every Outlook user (postguard-outlook-addon#132). | |
| PROD_ADDIN_HOST: addin.postguard.eu | |
| PROD_PKG_URL: https://postguard.eu/pkg | |
| PROD_CRYPTIFY_URL: https://storage.postguard.eu | |
| PROD_WEBSITE_URL: https://postguard.eu | |
| EDGE_ADDIN_HOST: addin.staging.postguard.eu | |
| EDGE_PKG_URL: https://staging.postguard.eu/pkg | |
| EDGE_CRYPTIFY_URL: https://storage.staging.postguard.eu | |
| EDGE_WEBSITE_URL: https://staging.postguard.eu | |
| defaults: | |
| run: | |
| working-directory: apps/outlook-addon | |
| jobs: | |
| check: | |
| name: Lint, typecheck & build | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - run: pnpm install --frozen-lockfile | |
| working-directory: . | |
| # These globs match what `.husky/pre-commit` already enforces via | |
| # lint-staged: `*.{js,mjs,ts}` and `*.{json,md,yml,yaml,css,html}`, | |
| # package-wide. A CI gate narrower than the hook it complements is | |
| # strictly weaker than it looks, and the src-only version excluded | |
| # exactly the build-critical files — webpack.config.js, which carries | |
| # the manifest AppDomain transform, and scripts/. | |
| # | |
| # Reaching parity cost two reformats, which is the argument for closing | |
| # the gap rather than against it: nine tracked files were unformatted, | |
| # and the hook would have rewritten them under whoever next touched a | |
| # taskpane template, turning a one-line change into a formatting diff. | |
| # `scripts/**`, not `scripts/*`, so a subdirectory cannot slip out. | |
| # | |
| # The `{json,md,yml,yaml,css,html}` glob would also match webpack's | |
| # output. What keeps it out is `dist/` in apps/outlook-addon/.gitignore, | |
| # which prettier 3 honours as a default ignore path: with an unformatted | |
| # file planted in dist/, the gate stays clean, and moving that .gitignore | |
| # aside makes it fail. So that entry is load-bearing here, not just a | |
| # build-artifact listing. (These steps run with the workflow's | |
| # apps/outlook-addon working directory, which is the .gitignore prettier | |
| # picks up.) | |
| - name: Lint (no errors, no warnings) | |
| run: pnpm exec eslint -c eslint.config.mjs --max-warnings=0 "src/**/*.{ts,js}" "test/**/*.ts" "scripts/**/*.mjs" webpack.config.js eslint.config.mjs | |
| - name: Prettier (format check) | |
| run: pnpm exec prettier --check "src/**/*.{ts,js}" "test/**/*.ts" "scripts/**/*.mjs" webpack.config.js eslint.config.mjs "**/*.{json,md,yml,yaml,css,html}" | |
| - run: pnpm typecheck | |
| # Outlook keys sideloaded and centrally-deployed updates on the | |
| # manifest's <Version>, which changesets does not touch. `version-packages` | |
| # syncs it; this refuses a commit where the two disagree. | |
| - name: Manifest version matches package.json | |
| run: pnpm check-version | |
| # Both origin sets are built, not just edge. The manifest transform | |
| # fails per target — it refuses to emit a manifest missing an | |
| # <AppDomain> for an origin that build needs — and the *production* | |
| # set is the one that ships. Building only edge left that failure to | |
| # surface inside the tag-time image build, which is late for the one | |
| # thing the transform exists to catch early. The extra webpack pass | |
| # costs a few seconds. | |
| # | |
| # Each is validated against dist/manifest.xml rather than the source: | |
| # the source still carries the localhost URLs, and the copy admins | |
| # sideload — the one the release job attaches — is what webpack emits. | |
| - name: Build (edge configuration) | |
| run: pnpm build | |
| env: | |
| # ADDIN_PUBLIC_URL too: unset, webpack falls back to the | |
| # production add-in origin, so this would build a manifest | |
| # pairing the prod origin with staging backends — a combination | |
| # that never ships. | |
| ADDIN_PUBLIC_URL: https://${{ env.EDGE_ADDIN_HOST }}/ | |
| PKG_URL: ${{ env.EDGE_PKG_URL }} | |
| CRYPTIFY_URL: ${{ env.EDGE_CRYPTIFY_URL }} | |
| POSTGUARD_WEBSITE_URL: ${{ env.EDGE_WEBSITE_URL }} | |
| - name: Validate the built manifest (edge) | |
| run: pnpm validate:dist | |
| - name: Build (production configuration) | |
| run: pnpm build | |
| env: | |
| ADDIN_PUBLIC_URL: https://${{ env.PROD_ADDIN_HOST }}/ | |
| PKG_URL: ${{ env.PROD_PKG_URL }} | |
| CRYPTIFY_URL: ${{ env.PROD_CRYPTIFY_URL }} | |
| POSTGUARD_WEBSITE_URL: ${{ env.PROD_WEBSITE_URL }} | |
| - name: Validate the built manifest (production) | |
| run: pnpm validate:dist | |
| test: | |
| name: Unit tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - run: pnpm install --frozen-lockfile | |
| working-directory: . | |
| - run: pnpm test | |
| # Every host this app can bake into its bundle must resolve, plus yivi.app, | |
| # which the build now refuses to produce a manifest without. A dead Cryptify | |
| # host reached production undetected (encryption4all/postguard-outlook-addon#132): | |
| # the add-in loaded fine and only failed when a user sent a file. | |
| # | |
| # This is a DNS check, not a reachability check — it says a name resolves, not | |
| # that anything answers on it. | |
| # | |
| # It reads the two files that actually carry the values as well as this | |
| # workflow's env, because #132 was a wrong `ARG` default in the Dockerfile — | |
| # exactly the copy a workflow-only check does not see. | |
| urls: | |
| name: Baked URLs resolve | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Check every configured host | |
| shell: bash | |
| # From the repo root: this workflow defaults to apps/outlook-addon, | |
| # which made the file paths below resolve one level deep and grep | |
| # exit 2 (file not found) rather than finding anything. | |
| working-directory: . | |
| run: | | |
| set -uo pipefail | |
| # `set -e` is deliberately absent — a correctly-pathed grep | |
| # that legitimately matches nothing exits 1 — which also means | |
| # grep's exit 2 on a missing file would not abort. Without this | |
| # guard a rename silently degrades the job to an env-only check | |
| # that still passes, and with no visible change: the two files | |
| # currently duplicate the env values exactly, so even the host | |
| # count stays the same. | |
| for f in apps/outlook-addon/Dockerfile apps/outlook-addon/webpack.config.js; do | |
| [ -f "$f" ] || { echo "::error::$f is missing; this check would silently stop covering it"; exit 1; } | |
| done | |
| # This workflow's env, plus every postguard.eu host hardcoded in | |
| # the two files that feed the build. Collected together so the | |
| # Dockerfile ARG defaults and webpack's envDefaults — the copy | |
| # that broke in #132 — are covered too. | |
| { | |
| printf '%s\n' "$PROD_PKG_URL" "$PROD_CRYPTIFY_URL" "$PROD_WEBSITE_URL" \ | |
| "https://$PROD_ADDIN_HOST" \ | |
| "$EDGE_PKG_URL" "$EDGE_CRYPTIFY_URL" "$EDGE_WEBSITE_URL" \ | |
| "https://$EDGE_ADDIN_HOST" | |
| # Every https host in the two files, not just *.postguard.eu | |
| # ones. The narrower pattern required a subdomain label, so it | |
| # matched neither file's bare-apex defaults | |
| # (`ARG POSTGUARD_WEBSITE_URL=https://postguard.eu`, | |
| # `ARG PKG_URL=https://postguard.eu/pkg`) — postguard.eu was | |
| # checked only because PROD_WEBSITE_URL happens to hold the | |
| # same string, the coincidence this job exists to stop | |
| # depending on. It also could not see a default that drifted | |
| # out of the family entirely, and missed yivi.app, which | |
| # webpack now refuses to build a manifest without. Deriving | |
| # the list from the files covers all three and leaves no | |
| # second list to keep in step. | |
| grep -ohE 'https://[A-Za-z0-9.:-]+' \ | |
| apps/outlook-addon/Dockerfile \ | |
| apps/outlook-addon/webpack.config.js | |
| } | sed -E 's#^https://##; s#[:/].*$##' | sort -u \ | |
| | grep -v '^localhost$' > /tmp/hosts | |
| # `grep -v` exits 1 when it filters everything, and without | |
| # `set -e` that would leave an empty host list and a green job. | |
| if [ ! -s /tmp/hosts ]; then | |
| echo "::error::collected no hosts to check; this would pass vacuously" | |
| exit 1 | |
| fi | |
| if ! command -v getent >/dev/null && ! command -v host >/dev/null \ | |
| && ! command -v nslookup >/dev/null; then | |
| echo "::error::no DNS lookup tool available; this check would pass vacuously" | |
| exit 1 | |
| fi | |
| echo "checking $(wc -l < /tmp/hosts) distinct hosts:" | |
| fail=0 | |
| while read -r host; do | |
| [ -z "$host" ] && continue | |
| # Fallback chain rather than getent alone: it is glibc-only | |
| # and absent on some images, so a single tool would report | |
| # every host dead and make this check useless-but-green-looking | |
| # the moment the runner image changes. | |
| if getent hosts "$host" >/dev/null 2>&1 \ | |
| || host "$host" >/dev/null 2>&1 \ | |
| || nslookup "$host" >/dev/null 2>&1; then | |
| echo " ok $host" | |
| else | |
| echo "::error::$host does not resolve, but the build can bake it in" | |
| fail=1 | |
| fi | |
| done < /tmp/hosts | |
| exit "$fail" | |
| nginx: | |
| # Qualified with the app, and it has to stay that way: website.yml has a | |
| # job that validates ITS nginx config, and a required status check is | |
| # matched by name across the whole repo. While both were called | |
| # `nginx config test`, requiring that name covered both copies — and | |
| # deleting either job would have left the name still reporting from the | |
| # other, so the gate would look alive while covering half of it. | |
| name: nginx config test (outlook-addon) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| # Mirrors the Dockerfile, which serves nginx/default.conf from | |
| # /etc/nginx/conf.d/default.conf on nginx:1.27-alpine, so the map | |
| # blocks are validated in the real http{} context. | |
| - name: Validate nginx config (nginx -t) | |
| run: | | |
| docker run --rm \ | |
| -v "${{ github.workspace }}/apps/outlook-addon/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" \ | |
| nginx:1.27-alpine nginx -t | |
| # The `image` job below only runs on a push, so without this the Dockerfile is | |
| # never built before merge — and this workflow's most invasive change is the | |
| # Dockerfile: repo-root context, new COPY paths, and ARGs deliberately placed | |
| # below `pnpm install`. Building without pushing gates that, needs no package | |
| # write grant, and makes the ARG-placement cache win observable in CI rather | |
| # than only on a laptop. `website.yml` does the same thing for the same reason. | |
| docker: | |
| name: Image builds (PR, no push) | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: . | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: docker/setup-buildx-action@v4 | |
| - name: Build (validate only, no push) | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: apps/outlook-addon/Dockerfile | |
| platforms: linux/amd64 | |
| push: false | |
| # Production values: the build refuses staging fallbacks in | |
| # production mode, and the manifest transform fails when an | |
| # <AppDomain> the target needs is absent, so these are the values | |
| # worth exercising. | |
| build-args: | | |
| ADDIN_PUBLIC_URL=https://${{ env.PROD_ADDIN_HOST }}/ | |
| PKG_URL=${{ env.PROD_PKG_URL }} | |
| CRYPTIFY_URL=${{ env.PROD_CRYPTIFY_URL }} | |
| POSTGUARD_WEBSITE_URL=${{ env.PROD_WEBSITE_URL }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| # Edge image on every push to main; the versioned image and the sideloadable | |
| # manifest on an `outlook-addin-v*` tag. Context is the repo root: the app | |
| # depends on @e4a/pg-js as workspace:*, so the image build has to install the | |
| # workspace and build the SDK first (see apps/outlook-addon/Dockerfile). | |
| image: | |
| name: Image (${{ github.ref_type == 'tag' && 'release' || 'edge' }}) | |
| # Gated on every check. Without this the job publishes in parallel with | |
| # them, so a red `urls` would not stop the same bundle shipping — which is | |
| # precisely the failure this workflow exists to prevent. | |
| needs: [check, test, urls, nginx] | |
| if: github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| packages: write | |
| defaults: | |
| run: | |
| working-directory: . | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve the target | |
| id: target | |
| shell: bash | |
| run: | | |
| if [ "$GITHUB_REF_TYPE" = tag ]; then | |
| version="${GITHUB_REF_NAME#outlook-addin-v}" | |
| declared="$(jq -r .version apps/outlook-addon/package.json)" | |
| if [ "$version" != "$declared" ]; then | |
| echo "::error::tag says $version, apps/outlook-addon says $declared" >&2 | |
| exit 1 | |
| fi | |
| { | |
| echo "tags=${IMAGE}:${version},${IMAGE}:latest" | |
| echo "addin_host=${PROD_ADDIN_HOST}" | |
| echo "pkg=${PROD_PKG_URL}" | |
| echo "cryptify=${PROD_CRYPTIFY_URL}" | |
| echo "website=${PROD_WEBSITE_URL}" | |
| echo "version=${version}" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| { | |
| echo "tags=${IMAGE}:edge" | |
| echo "addin_host=${EDGE_ADDIN_HOST}" | |
| echo "pkg=${EDGE_PKG_URL}" | |
| echo "cryptify=${EDGE_CRYPTIFY_URL}" | |
| echo "website=${EDGE_WEBSITE_URL}" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| # The production manifest is what admins sideload from, so it is | |
| # attached to the release with a stable versioned URL rather than | |
| # taken from whatever the addin host currently serves. | |
| # | |
| # Built and validated BEFORE the image is pushed. These four steps need | |
| # nothing from the pushed image, and validation can genuinely fail: | |
| # `office-addin-manifest validate` is a network call to Microsoft's | |
| # acceptance-test service, so their outage is enough. With the push | |
| # first, that left `:<version>` and `:latest` live with no release and | |
| # no manifest attached — admins sideloading the old manifest against a | |
| # new bundle, while sync-addons.mjs found nothing matching | |
| # `outlook-addin-v*` and took its keep-serving path, so the website went | |
| # on mirroring the pre-migration artifact with nothing recording the | |
| # mismatch. Ordering costs no wall clock and makes the gate real. | |
| - uses: pnpm/action-setup@v4 | |
| if: github.ref_type == 'tag' | |
| - uses: actions/setup-node@v6 | |
| if: github.ref_type == 'tag' | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - name: Build the production manifest | |
| if: github.ref_type == 'tag' | |
| run: pnpm install --frozen-lockfile && pnpm --filter postguard-outlook-addin build | |
| env: | |
| ADDIN_PUBLIC_URL: https://${{ steps.target.outputs.addin_host }}/ | |
| PKG_URL: ${{ steps.target.outputs.pkg }} | |
| CRYPTIFY_URL: ${{ steps.target.outputs.cryptify }} | |
| POSTGUARD_WEBSITE_URL: ${{ steps.target.outputs.website }} | |
| # The `check` job validates the edge- and production-configured | |
| # manifests it builds itself, not this one. This is the actual release | |
| # asset, and it differs from the edge copy in exactly what validation | |
| # inspects: the SourceLocation and icon origins, and the scoped | |
| # AppDomains. Validating it here is what makes "the file admins | |
| # sideload is validated" true of the released artifact. | |
| - name: Validate the manifest being released | |
| if: github.ref_type == 'tag' | |
| run: pnpm --filter postguard-outlook-addin validate:dist | |
| - name: Build and push | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: apps/outlook-addon/Dockerfile | |
| # amd64 only, matching what the standalone repo actually | |
| # published. arm64 would be new here and nothing exercises it | |
| # before a tag: there is no setup-qemu-action, and an emulated | |
| # `webpack --mode production` over a WASM-embedding bundle is | |
| # slow and OOM-prone. Worth adding deliberately, with a build | |
| # that runs on PRs, rather than discovering it at release time. | |
| platforms: linux/amd64 | |
| push: true | |
| tags: ${{ steps.target.outputs.tags }} | |
| build-args: | | |
| ADDIN_PUBLIC_URL=https://${{ steps.target.outputs.addin_host }}/ | |
| PKG_URL=${{ steps.target.outputs.pkg }} | |
| CRYPTIFY_URL=${{ steps.target.outputs.cryptify }} | |
| POSTGUARD_WEBSITE_URL=${{ steps.target.outputs.website }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| - name: Create the release with the manifest attached | |
| if: github.ref_type == 'tag' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh release create "$GITHUB_REF_NAME" \ | |
| --title "Outlook add-in ${{ steps.target.outputs.version }}" \ | |
| --notes "Sideload the attached manifest.xml, or pull ${IMAGE}:${{ steps.target.outputs.version }}." \ | |
| apps/outlook-addon/dist/manifest.xml |