Skip to content

GitHub Action ​

DANGER

The platform is under development, see the road map.

The Storyteller configuration action brings the resolved configuration of an annotation into a GitHub Actions job: into the environment of the job, where every later step finds its values as variables, into a file, or both. It installs sform and runs sform story config export <key> once, so the names, the masks and the refusals are those of Export to GitHub Actions.

yaml
# The later steps read APP_DB__HOST, APP_DB__PASSWORD, …
- uses: akobr/mono.me/src/Platform/GitHubAction@<commit>
  with:
    key: exe.northwind.invoicing.production
    export-env: true
    env-prefix: APP_
    cli-version: ${{ steps.sform.outputs.version }}   # packed first, see Until sform is published
    cli-source: ${{ runner.temp }}/sform
  env:
    STORYTELLER_ENDPOINT: https://storyteller.example.com/api
    STORYTELLER_CLIENT_CERTIFICATE: ${{ runner.temp }}/storyteller.pfx   # a certificate machine, see Sign-in
    STORYTELLER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.STORYTELLER_CERTIFICATE_PASSWORD }}
- run: dotnet run --project src/Billing   # AddEnvironmentVariables("APP_")

The action lives in the mono.me repository and has no release tags yet: pin a commit. It runs on Linux, macOS and Windows runners. No sform on NuGet has story config export yet, so every example installs the sform that the workflow packed in an earlier step (cli-version and cli-source), see Until sform is published.

Inputs ​

InputDefaultMeaning
keyrequiredThe annotation key of the configuration
project<organization>.<project>, passed as STORYTELLER_PROJECT
viewPassed as STORYTELLER_VIEW
endpointThe address of the API, /api included, passed as STORYTELLER_ENDPOINT
client-idPassed as STORYTELLER_CLIENT_ID; a GUID in a job with permissions: id-token: write signs in with the job's OIDC token
export-envfalseExport the resolved configuration into the environment of the job
env-prefixA prefix of every name, for example APP_; with export-env, and for output-file in the env format
allow-unprefixedfalseLet export-env write the names of the configuration without a prefix
allow-unresolvedfalseExport binding expressions that were not resolved as they are, instead of failing
maskboundbound masks the values that bindings produced, all every string and number
output-fileAlso write the resolved configuration to this file, best under $RUNNER_TEMP; a relative path is relative to the workspace
formatenvThe format of output-file: env, flat, properties, yaml, or json for the content alone
cli-versionrequiredThe exact version of 42.Platform.Cli (sform) to install
cli-sourceAn additional NuGet source of the tool, a URL or an absolute path

Set export-env, output-file or both; without either, the action fails before it installs anything. project, view, endpoint and client-id become STORYTELLER_* variables only when they are not empty, so an empty input keeps the variable that the job sets.

What the action does ​

  1. It checks the inputs.
  2. When the runner has no .NET 10 SDK, it runs actions/setup-dotnet@v6 (see .NET on the runner).
  3. It installs the cli-version of sform into a folder of its own under $RUNNER_TEMP, from nuget.org and cli-source, without reading the nuget.config of your repository. A second use of the action in the job reuses that folder.
  4. It runs sform story config export once, with one resolved and one effective read: sform writes the ::add-mask:: commands first, then the variables to GITHUB_ENV with export-env, then output-file, and a summary with the names of the variables and the number of masked values, never a value.

The step fails with the exit code of sform, 2 when it refuses the configuration or the options; see Export to GitHub Actions. sform sends anonymous usage data unless the job sets DO_NOT_TRACK: 1, see Telemetry.

Sign-in ​

Credentials are not inputs: sform reads them from the environment of the job or of the step, as in Sign in from a pipeline. An API key or a certificate names its project; client credentials and federated machines need project, or STORYTELLER_PROJECT or STORYTELLER_KEY in the job.

For a configuration with secrets, a certificate machine over mTLS is the recommendation, see Secrets need more than a credential. The job writes its .pfx file from a secret under $RUNNER_TEMP:

yaml
- run: printf '%s' "$CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/storyteller.pfx"
  env:
    CERTIFICATE: ${{ secrets.STORYTELLER_CERTIFICATE_BASE64 }}
- uses: akobr/mono.me/src/Platform/GitHubAction@<commit>
  with:
    key: exe.northwind.invoicing.production
    export-env: true
    env-prefix: APP_
    cli-version: ${{ steps.sform.outputs.version }}   # packed first, see Until sform is published
    cli-source: ${{ runner.temp }}/sform
  env:
    STORYTELLER_ENDPOINT: https://storyteller.example.com/api
    STORYTELLER_CLIENT_CERTIFICATE: ${{ runner.temp }}/storyteller.pfx
    STORYTELLER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.STORYTELLER_CERTIFICATE_PASSWORD }}

A federated machine is possible too, and needs no stored secret. With Secrets.Read, it receives secret values only when an Administrator sets RequireMtlsForSecretDelivery to false for the project, see below. This one has no Secrets.Read and reads the configuration of a sandbox, which has no secret bindings, into a file for the one step that needs it:

yaml
permissions:
  id-token: write
  contents: read
steps:
  - uses: akobr/mono.me/src/Platform/GitHubAction@<commit>
    with:
      key: exe.northwind.invoicing.sandbox
      project: acme.billing
      endpoint: https://storyteller.example.com/api
      client-id: 3f1c2a9e-5b7d-4c11-9e0a-2d4f6b8c1a07
      output-file: ${{ runner.temp }}/billing.env
      cli-version: ${{ steps.sform.outputs.version }}   # packed first, see Until sform is published
      cli-source: ${{ runner.temp }}/sform
  - run: docker compose --env-file "$RUNNER_TEMP/billing.env" up --detach

Secrets need more than a credential ​

A configuration whose bindings read secrets reaches the job only when the machine may receive them. The machine needs Secrets.Read (sform account machine create -r --scope secrets-read) and one of these sign-ins:

  • Recommended: a certificate machine over mTLS. It receives secret values under the default policy of a project, so the project keeps RequireMtlsForSecretDelivery on and every machine without a certificate still gets no secret value. The job writes the .pfx file from a secret under $RUNNER_TEMP. It needs an API behind a front end that forwards the client certificate. See Set up a certificate machine.
  • Possible: a federated machine (GitHub OIDC). It needs no stored secret, only permissions: id-token: write and the machine id, a GUID, in client-id. It receives secret values only when an Administrator sets RequireMtlsForSecretDelivery to false for the project: sform story kv policy set --require-mtls false.
  • Possible: an API key or client credentials, under the same change of the policy.

The policy is per project: turned off, it lets every machine of the project with Secrets.Read receive secret values, and every machine with Secrets.Write write secrets, without mTLS, with an API key, client credentials or a federated token, not only the machine of the job.

Without Secrets.Read or such a sign-in, the API withholds the secrets or returns the expressions of the secret bindings, and the action stops with exit code 2 before it exports or writes anything. allow-unresolved: true exports such expressions as they are; withheld secrets always stop the action.

Set up a certificate machine ​

sform account machine create without -k creates the default kind of the project, which is an API key unless an Administrator changed it. So a certificate machine takes -k certificate, and the project enables the kind first. Once:

  1. An Administrator enables Certificate for the project. sform account machine machine-auth prints the policy. With -k it sets the whole policy, so name the kinds it printed, the default kind first, add -k certificate, and add -l when the project has a certificate lifetime of its own.
  2. A user with the ContributorWithSecrets role creates the machine with Secrets.Read and writes its certificate to a file. machine create prints the password of the certificate once.
  3. The repository gets two secrets: STORYTELLER_CERTIFICATE_BASE64, the file in base64 on one line, and STORYTELLER_CERTIFICATE_PASSWORD, the password. Then delete the file.
bash
sform account machine machine-auth                              # the enabled kinds, the default kind and the lifetime
sform account machine machine-auth -k api-key -k certificate    # the kinds it printed, and certificate
sform account machine create -r --scope secrets-read -k certificate -o storyteller.pfx
base64 -w0 storyteller.pfx | gh secret set STORYTELLER_CERTIFICATE_BASE64
gh secret set STORYTELLER_CERTIFICATE_PASSWORD                  # paste the password that machine create printed
rm storyteller.pfx

The certificate expires after the certificate lifetime of the project, or after the days that -l of machine create gives. Before then, the two secrets need a new certificate and its password.

What later steps see ​

A variable in GITHUB_ENV reaches every later step of the job, actions of other authors included, and the log prints it in the env: block of every such step. Only masked values are hidden there, and a mask hides a value in the log, not from a step that reads the variable.

mask: bound, the default, masks the values that bindings produced, because the resolved read does not say which values are secret: it also masks public values that bindings produced, cannot recognise a secret stored as a plain value in the configuration, and masks a secret that a binding put into a longer string only as that whole string. Values shorter than four characters are not masked under bound; the summary names them. So export only what the job needs:

  • a prefix keeps the names apart;
  • mask: all masks every string and number, for a configuration that holds secrets as plain values;
  • output-file under $RUNNER_TEMP, without export-env, keeps the values out of the environment of the other steps.

Names ​

The names come from the configuration, so whoever edits the configuration could steer the later steps: PATH, NODE_OPTIONS, LD_PRELOAD or RESTORESOURCES change which tools run and where they get packages. export-env therefore needs env-prefix, for example APP_, or allow-unprefixed: true for a job where the people who edit the configuration may steer the job anyway.

Either way, sform refuses names that the runner reserves or that change how later steps or their tools run, compared without regard to case and with the prefix: PATH, HOME, BASH_ENV, NODE_OPTIONS, CLASSPATH, HTTP_PROXY, RESTORESOURCES and others, and every name that starts with GITHUB_, RUNNER_, ACTIONS_, LD_, DOTNET_, NUGET_, MSBUILD, GIT_ or STORYTELLER_. The whole list is under The refused names. No such list is complete, which is why the prefix is required. A value with U+0000, a variable longer than 32,767 characters and more than 512 KiB of variables are refused too.

Until sform is published ​

The sform on nuget.org is older than story config export. Until a release has it, the workflow packs sform from the repository and passes the folder as cli-source and the packed version as cli-version. The action of a commit runs story config export, so pack sform from the same commit or a newer one: an older sform has no such command and fails.

yaml
steps:
  - uses: actions/checkout@v4
    with:
      repository: akobr/mono.me
      ref: <commit>
      path: mono.me
      fetch-depth: 0   # the version of sform is computed from the history
  - uses: actions/setup-dotnet@v6
    with:
      dotnet-version: 10.0.x
  - id: sform
    shell: bash
    working-directory: mono.me   # so its global.json chooses the SDK, not the one of your repository
    run: |
      dotnet pack src/Platform/Cli/src -o "$RUNNER_TEMP/sform"
      package=$(basename "$RUNNER_TEMP"/sform/42.Platform.Cli.*.nupkg .nupkg)
      echo "version=${package#42.Platform.Cli.}" >> "$GITHUB_OUTPUT"
  - uses: akobr/mono.me/src/Platform/GitHubAction@<commit>
    with:
      key: exe.northwind.invoicing.production
      export-env: true
      env-prefix: APP_
      cli-version: ${{ steps.sform.outputs.version }}
      cli-source: ${{ runner.temp }}/sform
    env:
      STORYTELLER_ENDPOINT: https://storyteller.example.com/api
      STORYTELLER_CLIENT_CERTIFICATE: ${{ runner.temp }}/storyteller.pfx   # see Sign-in
      STORYTELLER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.STORYTELLER_CERTIFICATE_PASSWORD }}

.NET on the runner ​

sform is a .NET 10 tool: installing it needs the .NET 10 SDK, which also brings the runtime it runs on. When the runner has that SDK, the action uses it and changes nothing. When it does not, also when it has only the .NET 10 runtime or an older SDK, the action runs actions/setup-dotnet@v6 with 10.0.x, which changes DOTNET_ROOT and PATH for the rest of the job: the later steps run the .NET it installed. A job that needs another .NET sets it up itself, after the action or before it with .NET 10 included.

Released under the MIT License.