Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/build-changelog-scrubber-lambda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,31 @@ jobs:
retention-days: 1
if-no-files-found: error
path: ${{ env.BINARY_PATH }}
# The scrubber's link allowlist is embedded from config/assembler.yml at this ref, so this
# hash identifies exactly which allowlist the deployed Lambda runs with. The release workflow
# attaches the document to the GitHub release after a successful deploy, making the deployed
# identity observable (docs-eng-team#671).
- name: Compute allowlist identity
env:
GIT_REF: ${{ inputs.ref || github.ref }}
# language=bash
run: |
set -euo pipefail
sha="sha256:$(sha256sum config/assembler.yml | awk '{print $1}')"
commit="$(git rev-parse HEAD)"
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
jq -n \
--arg sha "$sha" \
--arg commit "$commit" \
--arg ref "$GIT_REF" \
--arg built_at "$built_at" \
'{schema_version: 1, artifact: "scrubber-allowlist-identity", allowlist_sha256: $sha, deployment_commit: $commit, git_ref: $ref, built_at: $built_at}' \
> changelog-scrubber-allowlist.json
cat changelog-scrubber-allowlist.json
- name: Archive allowlist identity
uses: actions/upload-artifact@v7
with:
name: changelog-scrubber-allowlist-identity
retention-days: 1
if-no-files-found: error
path: changelog-scrubber-allowlist.json
11 changes: 10 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ jobs:
with:
name: changelog-scrubber-lambda-binary

- name: Download allowlist identity
uses: actions/download-artifact@v8
with:
name: changelog-scrubber-allowlist-identity

- name: Create zip
run: |
zip -j "${ZIP_FILE}" ./bootstrap
Expand All @@ -223,12 +228,16 @@ jobs:
--function-name elastic-docs-v3-changelog-scrubber \
--zip-file "fileb://${ZIP_FILE}"

# The allowlist identity is attached only after update-function-code succeeded, so the
# presence of this asset on a release attests that the release's allowlist was deployed.
# Consumers resolve the deployed identity from the newest release carrying the asset
# (`docs-builder changelog scrubber-allowlist`, docs-eng-team#671).
- name: Attach Distribution to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.release-drafter.outputs.tag_name }}
REPO: ${{ github.repository }}
run: gh release upload --repo "$REPO" "$TAG_NAME" "${ZIP_FILE}"
run: gh release upload --repo "$REPO" "$TAG_NAME" "${ZIP_FILE}" changelog-scrubber-allowlist.json

release:
needs:
Expand Down
94 changes: 94 additions & 0 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4299,6 +4299,100 @@
}
]
},
{
"path": [
"changelog"
],
"name": "scrubber-allowlist",
"summary": "Resolve the link allowlist identity of the deployed changelog scrubber.",
"notes": "The scrubber Lambda embeds its link allowlist from config/assembler.yml at build time, so the\ndeployed allowlist can differ from any local checkout. The release pipeline attaches a\nchangelog-scrubber-allowlist.json asset to the GitHub release after each successful scrubber\ndeploy; this command resolves the identity from that asset. Without a --tag, the newest release\ncarrying the asset wins \u2014 the most recent deploy that passed the gated pipeline. Exits non-zero when\nno identity can be resolved: backfill plans must pin this identity and cannot be approved without it.",
"usage": "docs-builder changelog scrubber-allowlist [options]",
"examples": [],
"parameters": [
{
"role": "flag",
"name": "tag",
"type": "string",
"required": false,
"summary": "Release tag to resolve the identity from (e.g., \u0022v5.7.0\u0022). Defaults to the newest release carrying the identity asset."
},
{
"role": "flag",
"name": "assembler",
"type": "string",
"required": false,
"summary": "Path to a local assembler.yml to compare against the deployed allowlist. Defaults to config/assembler.yml when it exists; a mismatch is reported as a warning, not an error.",
"validations": [
{
"kind": "rejectSymbolicLinks"
},
{
"kind": "existing"
},
{
"kind": "fileExtensions",
"values": [
"yml",
"yaml"
]
}
]
},
{
"role": "flag",
"name": "owner",
"type": "string",
"required": false,
"summary": "GitHub owner of the repository whose releases carry the identity asset.",
"defaultValue": "elastic"
},
{
"role": "flag",
"name": "repo",
"type": "string",
"required": false,
"summary": "GitHub repository whose releases carry the identity asset.",
"defaultValue": "docs-builder"
},
{
"role": "flag",
"name": "log-level",
"shortName": "l",
"type": "enum",
"required": false,
"summary": "Minimum log level. Default: information",
"enumValues": [
"trace",
"debug",
"information",
"warning",
"error",
"critical",
"none"
]
},
{
"role": "flag",
"name": "config-source",
"shortName": "c",
"type": "enum",
"required": false,
"summary": "Override the configuration source: local, remote",
"enumValues": [
"local",
"remote",
"embedded"
]
},
{
"role": "flag",
"name": "skip-private-repositories",
"type": "boolean",
"required": false,
"summary": "Skip cloning private repositories"
}
]
},
{
"path": [
"changelog"
Expand Down
44 changes: 44 additions & 0 deletions docs/cli/changelog/cmd-scrubber-allowlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## Description

Resolve the link allowlist identity of the deployed changelog scrubber.

The changelog scrubber Lambda embeds its link allowlist from `config/assembler.yml` at build time, so the allowlist the deployed scrubber actually runs with can differ from any local checkout. Links attributed to repositories that are not on the deployed allowlist are silently stripped on publication, which makes the deployed identity a required input for backfill planning and public verification.

The release pipeline attaches a `changelog-scrubber-allowlist.json` asset to the GitHub release **after** the scrubber deploy succeeded, so the presence of the asset attests that the release's allowlist was deployed. This command resolves the identity from that asset:

- Without `--tag`, the newest (non-draft) release carrying the asset wins — that is the most recent deploy that passed the gated pipeline.
- With `--tag`, the identity is read from that specific release and the command fails when the release does not carry the asset (it predates identity publication, or its scrubber deploy never completed).

When a local `assembler.yml` is available (`--assembler`, or `config/assembler.yml` in the current directory), its hash is compared against the deployed identity. A mismatch is reported as a **warning**, not an error: it means link decisions must be validated against the deployed allowlist, not the local checkout.

The command exits non-zero when no identity can be resolved. Backfill plans pin this identity, and a plan cannot be approved without it.

## Identity document

The resolved asset is a small JSON document:

```json
{
"schema_version": 1,
"artifact": "scrubber-allowlist-identity",
"allowlist_sha256": "sha256:<64 hex characters>",
"deployment_commit": "<full 40-character commit SHA>",
"git_ref": "v5.7.0",
"built_at": "2026-08-01T12:00:00Z"
}
```

`allowlist_sha256` is the SHA-256 of the raw `config/assembler.yml` bytes at the release tag — the same value `sha256sum config/assembler.yml` reports at that ref, and the same bytes the Lambda embeds as its allowlist source.

## Examples

```sh
# Resolve the identity of the most recent gated deploy
docs-builder changelog scrubber-allowlist

# Resolve the identity a specific release deployed
docs-builder changelog scrubber-allowlist --tag v5.7.0

# Compare an explicit local assembler.yml against the deployed allowlist
docs-builder changelog scrubber-allowlist --assembler ./config/assembler.yml
```
7 changes: 7 additions & 0 deletions src/infra/docs-lambda-changelog-scrubber/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ The public repo allowlist is derived from `config/assembler.yml` (baked into the
Lambda image as an embedded resource at build time). Changes to `assembler.yml`
trigger a Lambda redeploy via CI.

The deployed allowlist's identity (SHA-256 of the embedded `assembler.yml`, plus the
build commit) is published as a `changelog-scrubber-allowlist.json` asset on the GitHub
release, attached only after a successful deploy. Resolve it with
`docs-builder changelog scrubber-allowlist` — backfill planning and verification pin
this identity so link decisions are checked against the deployed allowlist, not a
local checkout (docs-eng-team#671).

## Build

From a linux `x86_64` machine (or Docker):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

namespace Elastic.Changelog.AllowlistIdentity;

/// <summary>
/// Identifies exactly which link allowlist a changelog-scrubber Lambda deployment is running with.
/// The allowlist is embedded from <c>config/assembler.yml</c> at the release tag the Lambda was
/// built from, so the deployed identity is fully determined by that tag. The release pipeline
/// attaches this document as a release asset (<see cref="AssetName"/>) only after the Lambda
/// deploy succeeded, which makes "the newest release carrying the asset" the identity of the
/// most recent gated deploy.
/// </summary>
public sealed partial record ScrubberAllowlistIdentity
{
/// <summary>The name of the release asset this document is published as.</summary>
public const string AssetName = "changelog-scrubber-allowlist.json";

/// <summary>The artifact discriminator every identity document must carry.</summary>
public const string ArtifactKind = "scrubber-allowlist-identity";

/// <summary>The schema version this reader understands.</summary>
public const int CurrentSchemaVersion = 1;

[GeneratedRegex("^sha256:[0-9a-f]{64}$")]
private static partial Regex Sha256Format();

[GeneratedRegex("^[0-9a-f]{40}$")]
private static partial Regex CommitFormat();

/// <summary>Version of this document's shape; readers reject versions they don't understand.</summary>
[JsonPropertyName("schema_version")]
public required int SchemaVersion { get; init; }

/// <summary>What kind of document this is; always <see cref="ArtifactKind"/>.</summary>
[JsonPropertyName("artifact")]
public required string Artifact { get; init; }

/// <summary>Hash of the embedded <c>config/assembler.yml</c> bytes, as <c>sha256:</c> + 64 lower-case hex characters.</summary>
[JsonPropertyName("allowlist_sha256")]
public required string AllowlistSha256 { get; init; }

/// <summary>The docs-builder commit the deployed scrubber was built from (full 40-character SHA).</summary>
[JsonPropertyName("deployment_commit")]
public required string DeploymentCommit { get; init; }

/// <summary>The git ref (release tag) the scrubber build checked out.</summary>
[JsonPropertyName("git_ref")]
public string? GitRef { get; init; }

/// <summary>When the scrubber binary embedding this allowlist was built, in UTC.</summary>
[JsonPropertyName("built_at")]
public DateTimeOffset? BuiltAt { get; init; }

/// <summary>Adds a plain-English description of every problem in this identity to <paramref name="problems"/>.</summary>
public void Validate(IList<string> problems)
{
if (SchemaVersion != CurrentSchemaVersion)
problems.Add($"Unsupported allowlist identity schema version {SchemaVersion}; this reader understands version {CurrentSchemaVersion}.");
if (!string.Equals(Artifact, ArtifactKind, StringComparison.Ordinal))
problems.Add($"Expected artifact '{ArtifactKind}' but found '{Artifact}'.");
if (string.IsNullOrWhiteSpace(AllowlistSha256) || !Sha256Format().IsMatch(AllowlistSha256))
problems.Add($"The allowlist hash must look like sha256: plus 64 lower-case hex characters, but found '{AllowlistSha256}'.");
if (string.IsNullOrWhiteSpace(DeploymentCommit) || !CommitFormat().IsMatch(DeploymentCommit))
problems.Add($"The deployment commit must be a full 40-character lower-case hex SHA, but found '{DeploymentCommit}'.");
}

/// <summary>
/// Parses an identity document from JSON. Returns false with the reasons in
/// <paramref name="problems"/> when the document is malformed or fails validation.
/// </summary>
public static bool TryParse(string json, [NotNullWhen(true)] out ScrubberAllowlistIdentity? identity, out IReadOnlyList<string> problems)
{
var found = new List<string>();
identity = null;
try
{
identity = JsonSerializer.Deserialize(json, ScrubberAllowlistIdentityJsonContext.Default.ScrubberAllowlistIdentity);
}
catch (JsonException e)
{
found.Add($"The identity document is not valid JSON: {e.Message}");
}

if (identity is null && found.Count == 0)
found.Add("The identity document deserialized to null.");

identity?.Validate(found);
if (found.Count > 0)
identity = null;

problems = found;
return identity is not null;
}

/// <summary>
/// Computes the identity hash of an allowlist source (the raw bytes of <c>config/assembler.yml</c>),
/// as <c>sha256:</c> + 64 lower-case hex characters — the same value <c>sha256sum</c> reports in CI.
/// </summary>
public static string ComputeSha256(Stream content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}

[JsonSerializable(typeof(ScrubberAllowlistIdentity))]
internal sealed partial class ScrubberAllowlistIdentityJsonContext : JsonSerializerContext;
Loading
Loading