Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
/.github/ @cdcavell
/eng/repository-controls/ @cdcavell
/scripts/ @cdcavell
/tools/publish-x.cs @cdcavell
/.github/workflows/publish-x.yml @cdcavell
X_PUBLISHING.md @cdcavell

# Executable teaching samples
/samples/ @cdcavell
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/docs-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ on:
- 'tools/generate-feed.cs'
- 'tools/generate-sitemap.cs'
- 'tools/prepare-indexnow.cs'
- 'tools/publish-x.cs'
- 'tools/validate-doc-metadata.cs'
- 'tools/validate-docfx-template-baseline.cs'
- '.config/dotnet-tools.json'
Expand Down Expand Up @@ -88,6 +89,9 @@ jobs:
- name: Validate RSS feed generator contract
run: dotnet run --file tools/generate-feed.cs -- --self-test

- name: Validate X publisher contract without credentials
run: dotnet run --file tools/publish-x.cs -- --self-test

- name: Generate RSS feed
run: dotnet run --file tools/generate-feed.cs

Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ on:
- 'tools/generate-feed.cs'
- 'tools/generate-sitemap.cs'
- 'tools/prepare-indexnow.cs'
- 'tools/publish-x.cs'
- 'tools/validate-doc-metadata.cs'
- 'tools/validate-docfx-template-baseline.cs'
- '.config/dotnet-tools.json'
- '.github/workflows/publish-docs.yml'
- '.github/workflows/publish-x.yml'

workflow_dispatch:

Expand Down Expand Up @@ -239,3 +241,12 @@ jobs:
run: >-
echo "::warning::IndexNow notification failed after a successful Pages deployment;
the published documentation remains valid and the sitemap remains available for crawler discovery."

publish-x:
name: Publish eligible content to X
needs: deploy-docs
permissions:
contents: write
uses: ./.github/workflows/publish-x.yml
with:
deployed_revision: ${{ github.sha }}
199 changes: 199 additions & 0 deletions .github/workflows/publish-x.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
name: Publish New Learning Content to X

on:
workflow_call:
inputs:
deployed_revision:
description: Commit successfully deployed by the calling documentation workflow
required: true
type: string

workflow_dispatch:
inputs:
dry_run:
description: Show eligible publications without calling X or changing state
required: true
type: boolean
default: true

permissions: {}

concurrency:
group: publish-learning-to-x
cancel-in-progress: false

env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
STATE_BRANCH: automation/x-publisher-state
STATE_FILE: x-publisher-state.json
DEPLOYED_REVISION_URL: https://asibackbone.github.io/Learning/deployment-revision.txt
DEPLOYED_SITEMAP_URL: https://asibackbone.github.io/Learning/sitemap.xml

jobs:
dry-run:
name: Preview X publications
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- name: Checkout repository without credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0

- name: Setup .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json

- name: Load deployed revision, sitemap, and state
id: publication
shell: bash
run: |
set -euo pipefail

deployed_revision="$(curl --fail --silent --show-error --location --max-time 20 "$DEPLOYED_REVISION_URL")"
deployed_revision="${deployed_revision//$'\r'/}"
deployed_revision="${deployed_revision//$'\n'/}"
git cat-file -e "${deployed_revision}^{commit}"
git merge-base --is-ancestor "$deployed_revision" origin/main

curl --fail --silent --show-error --location --max-time 20 \
--output "$RUNNER_TEMP/deployed-sitemap.xml" \
"$DEPLOYED_SITEMAP_URL"

if git fetch --no-tags origin "refs/heads/$STATE_BRANCH:refs/remotes/origin/$STATE_BRANCH"; then
git show "origin/$STATE_BRANCH:$STATE_FILE" > "$RUNNER_TEMP/$STATE_FILE"
fi

echo "deployed_revision=$deployed_revision" >> "$GITHUB_OUTPUT"

- name: Preview publications without X credentials
env:
DEPLOYED_REVISION: ${{ steps.publication.outputs.deployed_revision }}
run: >-
dotnet run --file tools/publish-x.cs --
--head "$DEPLOYED_REVISION"
--sitemap "$RUNNER_TEMP/deployed-sitemap.xml"
--state "$RUNNER_TEMP/$STATE_FILE"
--account jackdaw-patio
--dry-run

publish:
name: Publish eligible content to X
if: >-
${{
github.event_name != 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' && !inputs.dry_run)
}}
runs-on: ubuntu-latest
environment: jackdaw-patio-x

permissions:
contents: write

steps:
- name: Checkout trusted default branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
persist-credentials: false
fetch-depth: 0
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

- name: Setup .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json

- name: Resolve deployed publication boundary
id: publication
shell: bash
env:
EXPECTED_REVISION: ${{ inputs.deployed_revision }}
run: |
set -euo pipefail

deployed_revision=""
for attempt in 1 2 3 4 5 6; do
deployed_revision="$(curl --fail --silent --show-error --location --max-time 20 "$DEPLOYED_REVISION_URL")"
deployed_revision="${deployed_revision//$'\r'/}"
deployed_revision="${deployed_revision//$'\n'/}"

if [[ -z "${EXPECTED_REVISION:-}" || "$deployed_revision" == "$EXPECTED_REVISION" ]]; then
break
fi

if [[ "$attempt" == "6" ]]; then
echo "Published revision '$deployed_revision' did not reach expected revision '$EXPECTED_REVISION'." >&2
exit 1
fi

sleep 10
done

git cat-file -e "${deployed_revision}^{commit}"
git merge-base --is-ancestor "$deployed_revision" origin/main
curl --fail --silent --show-error --location --max-time 20 \
--output "$RUNNER_TEMP/deployed-sitemap.xml" \
"$DEPLOYED_SITEMAP_URL"
echo "deployed_revision=$deployed_revision" >> "$GITHUB_OUTPUT"

- name: Prepare durable publisher state worktree
id: state
shell: bash
env:
DEPLOYED_REVISION: ${{ steps.publication.outputs.deployed_revision }}
run: |
set -euo pipefail

state_directory="$RUNNER_TEMP/x-publisher-state"
if git fetch --no-tags origin "refs/heads/$STATE_BRANCH:refs/remotes/origin/$STATE_BRANCH"; then
git worktree add --detach "$state_directory" "origin/$STATE_BRANCH"
else
git worktree add --detach "$state_directory" "$DEPLOYED_REVISION"
fi

echo "directory=$state_directory" >> "$GITHUB_OUTPUT"

- name: Publish and record receipts
env:
X_API_KEY: ${{ secrets.X_API_KEY }}
X_API_KEY_SECRET: ${{ secrets.X_API_KEY_SECRET }}
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
X_ACCOUNT_USER_ID: ${{ vars.X_ACCOUNT_USER_ID }}
DEPLOYED_REVISION: ${{ steps.publication.outputs.deployed_revision }}
STATE_DIRECTORY: ${{ steps.state.outputs.directory }}
run: >-
dotnet run --file tools/publish-x.cs --
--head "$DEPLOYED_REVISION"
--sitemap "$RUNNER_TEMP/deployed-sitemap.xml"
--state "$STATE_DIRECTORY/$STATE_FILE"
--account jackdaw-patio
--account-id "$X_ACCOUNT_USER_ID"

- name: Persist receipts and checkpoint
shell: bash
env:
GH_TOKEN: ${{ github.token }}
STATE_DIRECTORY: ${{ steps.state.outputs.directory }}
DEPLOYED_REVISION: ${{ steps.publication.outputs.deployed_revision }}
run: |
set -euo pipefail

git -C "$STATE_DIRECTORY" add "$STATE_FILE"
if git -C "$STATE_DIRECTORY" diff --cached --quiet; then
echo "Publisher state already reflects $DEPLOYED_REVISION."
exit 0
fi

git -C "$STATE_DIRECTORY" config user.name github-actions[bot]
git -C "$STATE_DIRECTORY" config user.email 41898282+github-actions[bot]@users.noreply.github.com
git -C "$STATE_DIRECTORY" commit -m "Record X publications through $DEPLOYED_REVISION"
gh auth setup-git
git -C "$STATE_DIRECTORY" push origin "HEAD:refs/heads/$STATE_BRANCH"
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Learning releases are archival and citation snapshots of educational material. T

### Added

- Optional post-deployment X publication with source-frontmatter selection,
durable receipt/checkpoint state, canonical-URL reconciliation, protected
OAuth credentials, deterministic dry runs, and offline contract validation.
- Durable stable-release evidence publishing release notes, a samples SPDX SBOM, a release-evidence manifest, hashes, and provenance attestations.
- Shared repository formatting, security-policy, workflow-validation, support, and maintainer baselines.

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,22 @@ Use the canonical project surfaces for deeper information rather than treating t
- **Security policy:** [SECURITY.md](SECURITY.md)
- **Citation metadata:** [CITATION.cff](CITATION.cff)
- **Licensing details:** [LICENSING.md](LICENSING.md)
- **X publication operations:** [X_PUBLISHING.md](X_PUBLISHING.md)

### Search discovery and change notification

The XML sitemap is the complete canonical discovery inventory for the published Learning site. IndexNow complements that inventory with a narrower post-deployment signal: after GitHub Pages deploys successfully, the publication workflow notifies participating search engines only about canonical Learning URLs that were added, modified, or removed since the previously deployed revision. IndexNow does not replace the sitemap and does not determine whether documentation is publishable.

The site publishes `deployment-revision.txt` so the next publication can compare against the revision actually represented by GitHub Pages rather than assuming the immediately preceding commit was deployed. On the first run, the workflow falls back to the push base revision. The public `indexnow-key.txt` file verifies control of the `/Learning/` URL space through IndexNow `keyLocation`; it is intentionally public protocol verification material, not a private workflow credential. IndexNow service failures are reported as non-blocking warnings after deployment.

An optional, separate post-deployment publisher can announce newly eligible
`feed: true` documents on X. It consumes the same source frontmatter as RSS but
does not read or modify `feed.xml`. Durable receipts, canonical-URL
reconciliation, and a deployed-revision cursor provide retryable,
effectively-once delivery without making X availability a documentation
deployment dependency. See the [X Publication Runbook](X_PUBLISHING.md) for the
security boundary, setup, dry-run, retry, recovery, and disable procedures.

Issues are best used for concrete repository work; Learning Discussions are better suited to exploratory architecture questions, tutorial proposals, alternatives, design debates, and community examples.

## Project Status
Expand Down
100 changes: 100 additions & 0 deletions X_PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# X Publication Runbook

Learning can optionally announce newly deployed `feed: true` documents through
the Jackdaw Patio X account. The `Publish Documentation` workflow invokes the
separate `Publish New Learning Content to X` reusable workflow only after the
Pages deployment succeeds. X availability therefore cannot block or roll back
the documentation deployment, RSS feed, sitemap, or IndexNow notification.

## Publication boundary

The publisher fetches the public `deployment-revision.txt` and `sitemap.xml`,
then verifies that the deployed revision is a repository commit reachable from
`main`. It compares that revision with the cursor stored on
`automation/x-publisher-state`.

A document is selected only when it is Markdown under `docs/`, changes from
missing or `feed: false` to valid `feed: true` metadata, and has a canonical URL
in the deployed sitemap. Content edits, metadata updates, deletions, and Git
renames of already eligible documents do not create another post. RSS and X are
sibling consumers of source frontmatter; the X publisher does not read or
modify `feed.xml`.

## Initial setup

1. Create a protected GitHub Environment named `jackdaw-patio-x`. Restrict it to
trusted `main` deployments and add required reviewers if another maintainer
is available.
2. Configure OAuth 1.0a user-context credentials as environment secrets:
`X_API_KEY`, `X_API_KEY_SECRET`, `X_ACCESS_TOKEN`, and
`X_ACCESS_TOKEN_SECRET`.
3. Configure the expected numeric account identifier as the environment variable
`X_ACCOUNT_USER_ID`.
4. Give the X application only the read/write permissions needed to inspect the
account timeline and create a post.
5. Manually run the workflow with `dry_run` left enabled. This job has read-only
GitHub permissions, no protected environment, and no X credentials.
6. Manually run with `dry_run` disabled to initialize
`automation/x-publisher-state` at the currently deployed revision. That first
run intentionally does not backfill the existing archive.

Do not place OAuth credentials in repository variables, source, workflow input,
logs, issues, or pull requests. The production API root is fixed in the tool and
cannot be overridden by repository or workflow input.

## Delivery and retry behavior

The post text is deterministic:

```text
New from ASI Backbone Learning:

{title}

{canonicalUrl}
```

Long titles are truncated at a Unicode text-element boundary while preserving
the canonical URL. Before every create call, the publisher searches recent
account posts for that URL. A successful create or reconciliation writes its
post ID and source blob SHA immediately. The cursor advances only after every
candidate succeeds.

HTTP `400` is treated as a publisher/content defect; `401` and `403` indicate a
credential or application-permission failure. `429`, `5xx`, and pre-response
connection failures receive bounded retries. An ambiguous timeout is reconciled
against recent posts before the run fails. X does not provide an application
idempotency key for this operation, so receipts plus URL reconciliation provide
effectively-once behavior with a small residual duplicate risk if X accepts a
post but neither its response nor the new post is observable during recovery.

The workflow stops on the first unresolved candidate. A later successful Pages
deployment or a controlled manual live run retries it because the durable cursor
has not advanced.

## Manual validation and recovery

Run the complete offline contract suite without secrets or network access:

```bash
dotnet run --file tools/publish-x.cs -- --self-test
```

Use the workflow's default manual dry run to inspect the candidates and exact
post text for the deployed revision. If a post succeeded but state persistence
failed, rerun the live workflow: recent-post reconciliation restores the missing
receipt before attempting a create.

For credential rotation, replace all four environment secrets as one coordinated
change, keep the account ID fixed, then run a dry run followed by a controlled
live run. For a wrong cursor or malformed manifest, do not delete receipts or
force-push the state branch. Repair it through a reviewed commit that preserves
valid receipts, then rerun manually.

## Disabling publication

Disable the `Publish New Learning Content to X` workflow or remove the
`jackdaw-patio-x` environment approval. Documentation deployment remains
independent. Retain the state branch so re-enabling the publisher does not
reannounce previously recorded content. Revoke X credentials when the publisher
is retired rather than merely deleting the workflow secrets.
Loading
Loading