Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
13 changes: 12 additions & 1 deletion .github/actions/nuget-oidc-publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ name: nuget-oidc-publish
description: >-
Exchanges a GitHub OIDC token for a short-lived nuget.org API key via
NuGet trusted publishing, pushes every .nupkg in the package directory
to nuget.org, and attaches the same files to the GitHub Release.
to nuget.org, attaches the same files to the GitHub Release, and then
publishes that release.
The surrounding job must grant `id-token: write` and `contents: write`.

inputs:
Expand Down Expand Up @@ -56,3 +57,13 @@ runs:
gh release upload "${RELEASE_TAG}" "${FILES[@]}" \
--repo "${GITHUB_REPOSITORY}" \
--clobber

- name: Publish GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
run: |
gh release edit "${RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--draft=false
1 change: 1 addition & 0 deletions .github/workflows/_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ jobs:
run: |
gh release create "${{ needs.plan-release.outputs.release_tag }}" \
--target "${{ needs.plan-release.outputs.release_sha }}" \
--draft \
${PRERELEASE_FLAG} \
--title "${{ needs.plan-release.outputs.release_tag }}" \
--notes-file CHANGES.md
Expand Down
2 changes: 1 addition & 1 deletion docs/design-docs/Data-Model.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ All entity IDs use **UUIDv7** (`Guid.CreateVersion7()` in .NET). UUIDv7 embeds a
| Field | .NET Type | Description |
|-------|-----------|-------------|
| `Id` | `Guid` | Primary key (UUIDv7) |
| `Key` | `string` | Configuration key using colon hierarchy (e.g., `Logging:LogLevel:Default`) |
| `Key` | `string` | Configuration key. Must match `^[A-Za-z][A-Za-z0-9.:_-]*$` (max 500 chars, immutable after creation). Colon-separated hierarchy is the recommended convention (e.g., `Logging:LogLevel:Default`) |
| `OwnerId` | `Guid` | ID of the owning template or project |
| `OwnerType` | `ConfigEntryOwnerType` | Enum: `Template`, `Project` |
| `ValueType` | `string` | .NET type name (see supported types below) |
Expand Down
2 changes: 1 addition & 1 deletion docs/design-docs/Domain-Model.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ An individual configuration key-value pair with type metadata, scope variants, a
| Field | Type | Description |
|-------|------|-------------|
| `id` | Guid (UUIDv7) | Unique identifier, generated via `Guid.CreateVersion7()` |
| `key` | string | Configuration key using colon-separated hierarchy (e.g., `Logging:LogLevel:Default`) |
| `key` | string | Configuration key. Must match `^[A-Za-z][A-Za-z0-9.:_-]*$` (start with a letter; letters, digits, and `.`, `:`, `_`, `-` thereafter). Colon-separated hierarchy is the recommended convention (e.g., `Logging:LogLevel:Default`). Max 500 chars. Immutable after creation. |
| `ownerId` | Guid | ID of the owning template or project |
| `ownerType` | `template` or `project` | Whether this entry belongs to a template or a project |
| `valueType` | string | .NET type name: `String`, `Int32`, `Int64`, `Double`, `Decimal`, `Boolean`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` |
Expand Down
1 change: 1 addition & 0 deletions docs/guide/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ curl -X POST http://localhost:8080/api/config-entries \
Notes:

- `ownerType` is either `Project` or `Template`
- `key` must match `^[A-Za-z][A-Za-z0-9.:_-]*$` (start with a letter; letters, digits, and `.`, `:`, `_`, `-` thereafter; max 500 chars). Immutable after creation.
- The first value with no `scopes` is the default
- Use `{{variableName}}` in values to reference variables
- Sensitive values are masked as `"***"` -- add `?decrypt=true` to see them
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Variables are named values you can reference inside configuration entries using

This page covers what a variable looks like, who can see it, how its value gets baked into a published snapshot, and the edge cases worth knowing.

> **Whitespace inside the braces is allowed.** `{{ApiBase}}`, `{{ ApiBase }}`, `{{\tApiBase\t}}` all resolve to the same variable. Whitespace is stripped from the captured name before lookup but preserved in the original token if the placeholder fails to resolve.

## Anatomy of a variable

A variable has a name, an ownership tier, and one or more values. Each value can be qualified with scope dimensions like `Environment` or `Region`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
using System.Globalization;
using System.Text.RegularExpressions;
using GroundControl.Api.Features.ConfigEntries.Contracts;
using GroundControl.Persistence.Contracts;
using GroundControl.Persistence.Stores;

namespace GroundControl.Api.Features.ConfigEntries;

internal static class ConfigEntryValidation
internal static partial class ConfigEntryValidation
{
/// <summary>
/// Allowed shape for a config entry key: starts with a letter, then any mix of letters,
/// digits, and the separators <c>.</c>, <c>:</c>, <c>_</c>, <c>-</c>.
/// </summary>
public const string KeyPattern = "^[A-Za-z][A-Za-z0-9.:_-]*$";

/// <summary>
/// Human-readable description of <see cref="KeyPattern"/>, surfaced verbatim in 400 responses.
/// </summary>
public const string KeyPatternErrorMessage = "Key must start with a letter and contain only letters, digits, '.', ':', '_', or '-'.";

[GeneratedRegex(KeyPattern, RegexOptions.Compiled)]
private static partial Regex KeyRegex { get; }

public static bool IsValidKey(string key) => !string.IsNullOrEmpty(key) && KeyRegex.IsMatch(key);

private static readonly HashSet<string> AllowedValueTypes = new(StringComparer.OrdinalIgnoreCase)
{
"String",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ namespace GroundControl.Api.Features.ConfigEntries.Contracts;
internal sealed record CreateConfigEntryRequest
{
/// <summary>
/// Gets the configuration key.
/// Gets the configuration key. Must start with a letter and contain only letters, digits, or
/// the separators <c>.</c>, <c>:</c>, <c>_</c>, <c>-</c>.
/// </summary>
/// <remarks>Maximum length: 500 characters.</remarks>
[Required]
[MaxLength(500)]
[RegularExpression(ConfigEntryValidation.KeyPattern, ErrorMessage = ConfigEntryValidation.KeyPatternErrorMessage)]
public required string Key { get; init; }

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public CreateConfigEntryValidator(IScopeStore scopeStore)

public async Task<ValidatorResult> ValidateAsync(CreateConfigEntryRequest instance, ValidationContext context, CancellationToken cancellationToken = default)
{
if (!ConfigEntryValidation.IsValidKey(instance.Key))
{
return ValidatorResult.Fail(ConfigEntryValidation.KeyPatternErrorMessage, nameof(instance.Key));
}

if (!ConfigEntryValidation.IsValidValueType(instance.ValueType))
{
return ValidatorResult.Fail($"ValueType '{instance.ValueType}' is not supported.", nameof(instance.ValueType));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ internal static partial class PlaceholderScanner
{
/// <summary>
/// Gets the regex used to identify <c>{{name}}</c> placeholders. The single capture group is
/// the placeholder name. Exposed to <see cref="VariableInterpolator"/> so the substitution
/// path uses the exact same pattern as the scan path.
/// the placeholder name. Optional inner whitespace is permitted (<c>{{ name }}</c>) and is
/// stripped by the capture group, so unresolved placeholder names are reported without it.
/// Exposed to <see cref="VariableInterpolator"/> so the substitution path uses the exact same
/// pattern as the scan path.
/// </summary>
[GeneratedRegex(@"\{\{(\w+)\}\}")]
[GeneratedRegex(@"\{\{\s*(\w+)\s*\}\}")]
internal static partial Regex PlaceholderPattern { get; }

/// <summary>
Expand Down
Loading
Loading